Skip to content

Tenancy and auth

Lockwell is multi-tenant by design. An app maps each of its customers (an org, a workspace, a project) to a tenant, and every tenant is isolated: separate buckets, separate keys, separate quota.

This page covers the model (tenants, scoped keys, native bearer tokens, signed URLs, admin tokens) and the security posture that backs it.

The model at a glance

ConceptWhat it isIssued byUsed for
TenantAn isolated namespace (your "org")Admin API / app kitContaining one customer's buckets and objects
Access keyaccessKeyId + secretKey, scoped to a tenantAdmin API / app kitS3 SigV4, and minting native tokens
Native bearer token (lwtk_)Short-lived token minted from an access keyThe native client (auto)The native JSON data plane
Native signed URLA method/resource/TTL-bound URL, no token neededA native clientBrowser-direct upload/download
Admin token (lwadm_)Bearer token with an RBAC rolelockwell admin-token createThe JSON Admin API

Tenants

A tenant is the unit of isolation. Provisioning is idempotent-ish: creating a tenant that already exists is not an error, so you can call provisionTenant on every customer sign-in without a "does it exist?" check.

ts
const { tenantId, created, key } = await kit.provisionTenant("acme", {
  name: "Acme Inc",
  defaultBucket: "inbox",
});
go
res, err := kit.ProvisionTenant(ctx, "acme", lockwellkit.ProvisionTenantInput{
 Name:          "Acme Inc",
 DefaultBucket: "inbox",
})
java
ProvisionResult res = kit.provisionTenant("acme",
    new LockwellKit.ProvisionOptions().tenantName("Acme Inc").defaultBucket("inbox"));

Map your customer's id to a Lockwell tenant id (e.g. org_acme becomes tenant acme). A credential's tenant is derived server-side from the credential itself, never from a request path, so one customer's request can never reach another's data. See isolation below. Tenants are disabled or deleted through the Admin API (reason required, retention and legal-hold gated). See the Admin API reference.

Scoped access keys

An access key is an accessKeyId + secretKey bound to a tenant. The secret is returned exactly once on create and on rotate. Lockwell stores only the encrypted secret and can never show it again, so persist it in your own datastore (encrypted) the moment you receive it.

The secret is shown once There is no read-back endpoint for a secret. If you lose it, rotate the key to mint

a new one. Capture accessKeyId and secretKey from the create or rotate response before doing anything else. :::

Keys carry a scope in a small policy grammar: per-operation verbs plus optional bucket/prefix narrowing.

text
read,write,delete                          # full data-plane access for the tenant
op=read,write,delete:bucket=inbox          # the same, restricted to one bucket
op=read:bucket=inbox:prefix=incoming/      # read-only, one bucket, one prefix

The four verbs are read, write, delete, and admin. The app kit's provisionTenant mints a data key (read,write,delete, optionally bucket-scoped). It never hands back a key carrying the admin verb, so a provisioned key cannot manage buckets or policy. (When you ask the kit to create a default bucket, it mints a transient admin-scoped key for that one bucket and revokes it immediately.)

The scope grammar in detail

A scope string is one of two forms:

  • Verb list. A comma-separated subset of read, write, delete, admin applied to the whole tenant. Example: read,write.
  • Qualified form. op=<verbs>:bucket=<bucket>:prefix=<prefix> where bucket and prefix narrow the verbs. prefix requires bucket. Example: op=read,write:bucket=uploads:prefix=tenants/acme/.

Which verb each operation needs:

OperationVerb
GET, HEAD, list objects/versions, get tags/retention/legal-holdread
PUT, copy, multipart, set tags, set retention/legal-holdwrite
delete object, batch delete, abort multipartdelete
create/delete bucket, set versioning, set notificationsadmin

The same scope is enforced on every surface. A read-only key cannot write over S3, the native API, or a signed URL.

Create, rotate, revoke, expire

The key lifecycle runs through the Admin API (or the app kit's underlying admin client). Every mutation accepts dryRun to preview without applying, and the destructive ones (revokeKey) take a required, audited reason.

ts
// Create a precisely-scoped key. The secret is returned once.
const k = await admin.createKey("acme", {
  scopes: "op=read,write:bucket=uploads:prefix=incoming/",
  expiresAt: "2026-12-31", // RFC-3339, YYYY-MM-DD, or "never"
});
console.log(k.accessKeyId, k.secretKey); // store both now

// Rotate: revoke this key and mint a replacement. Restate the scope you want
// kept (an empty rotate resets it to the server default).
const rotated = await admin.rotateKey("acme", k.accessKeyId, {
  scopes: "op=read,write:bucket=uploads:prefix=incoming/",
});
console.log(rotated.accessKeyId, rotated.secretKey, rotated.oldAccessKeyId);

// Change expiry / scope later (admin UI or API). Then revoke when retired.
await admin.revokeKey("acme", k.accessKeyId, { reason: "employee offboarded" });
go
nk, _, err := admin.CreateKey(ctx, "acme", lockwelladmin.CreateKeyInput{
 Scopes:    "op=read,write:bucket=uploads:prefix=incoming/",
 ExpiresAt: "2026-12-31",
})
// nk.AccessKeyID, nk.SecretKey. Store both now.

rotated, _, err := admin.RotateKey(ctx, "acme", nk.AccessKeyID, lockwelladmin.RotateKeyInput{
 Scopes: "op=read,write:bucket=uploads:prefix=incoming/", // restate to keep it
})
// rotated.AccessKeyID + rotated.SecretKey are new; rotated.OldAccessKeyID is revoked.

_, _, err = admin.RevokeKey(ctx, "acme", nk.AccessKeyID,
 lockwelladmin.RevokeKeyInput{Reason: "employee offboarded"})
java
NewKey k = admin.createKey("acme",
    new CreateKeyOptions(null, "op=read,write:bucket=uploads:prefix=incoming/", "2026-12-31"));
// k.key().accessKeyId(), k.secretKey(). Store both now.

NewKey rotated = admin.rotateKey("acme", k.key().accessKeyId(),
    new RotateKeyOptions("op=read,write:bucket=uploads:prefix=incoming/", null));
// rotated.key().accessKeyId() + rotated.secretKey() are new; rotated revokes the old.

admin.revokeKey("acme", k.key().accessKeyId(), "employee offboarded");

Notes on each:

  • Create returns the secret once. There is no read-back.
  • Rotate revokes the old key and mints a replacement, so the new accessKeyId and secret both change and the response reports oldAccessKeyId. The previous key stops authenticating at once. Restate the scope you want on the new key, since an empty rotate falls back to the server default. Store the new pair, then swap your app over to it.
  • Expiry is a wall-clock cutoff (expiresAt). After it passes, the key fails closed on every surface. An expired key cannot mint a native token, and any outstanding native token it minted fails closed too.
  • Revoke is immediate for an in-process admin mutation (the access-key cache is busted) and propagates within the cache TTL (about 5s) for an out-of-process CLI revoke. A revoked key fails closed everywhere.

Native bearer tokens (lwtk_)

The native data plane authenticates with a short-lived bearer token, minted from an access key. You never pass a token in, only the key.

The native client manages the token for you. It mints on first use (POST /api/v1/auth/token), caches it until shortly before expiry, refreshes transparently, and re-mints once on a 401. Minting is concurrency-safe (single-flight), so a burst of requests shares one in-flight mint instead of one per request.

The token is stateless and signed:

text
lwtk_<base64url(payloadJSON)>.<base64url(HMAC-SHA256(payload, signingKey))>

The payload carries {accessKeyId, tenantId, accountId, scopes, iat, exp}. The signing key is derived from the deployment's at-rest master key via HKDF under a dedicated label, so it is per-deployment and never configured separately. The HMAC is compared in constant time. Two consequences matter:

  • The tenant comes from the token, not the request. A native call's tenant is whatever the signed token says. There is no tenant in the URL path to spoof.
  • Revocation is honored promptly. After the (DB-free) signature and expiry check, the middleware re-resolves the underlying access key through the same cached lookup the SigV4 path uses. A revoked or expired key, or a disabled tenant, fails closed with 401 even though the token's signature is still valid. The token can never outlive the key it points at, nor out-scope it.

You almost never construct a token by hand. Hand the client your access key and let it manage the token. The secret and the live token are never logged or rendered (toString/inspect/JSON all redact them). The token TTL is set by security.native_api_token_ttl (default 1h).

Native signed URLs

A signed URL grants a single, method- and resource-bound, time-limited operation with no bearer token. It is the way to hand a browser a direct upload or download. The URL carries its authorization in a token query parameter, is bound to one bucket+key and one method, and can never exceed the minting key's scope (the server re-checks scope and bucket policy both at mint time and at access time). A read-only key asked to sign a PUT is denied 403.

The S3 client presigns GET only. The native API signs both GET and PUT, and the signed PUT is the sanctioned browser-upload path:

ts
const up = await kit.signedUploadUrl(creds, "inbox", "photo.jpg", { ttlSeconds: 300, contentType: "image/jpeg" });
const down = await kit.signedDownloadUrl(creds, "inbox", "photo.jpg", { ttlSeconds: 300 });
go
up, _ := kit.SignedUploadURL(ctx, creds, "inbox", "photo.jpg",
 lockwellkit.SignedUploadURLInput{TTLSeconds: 300, ContentType: "image/jpeg"})
down, _ := kit.SignedDownloadURL(ctx, creds, "inbox", "photo.jpg", 300)
java
BrowserSignedUrl up = kit.signedUploadUrl(creds, "inbox", "photo.jpg",
    Duration.ofMinutes(5), "image/jpeg");
BrowserSignedUrl down = kit.signedDownloadUrl(creds, "inbox", "photo.jpg",
    Duration.ofMinutes(5));

TTL is clamped server-side to the deployment's maximum (security.max_presign_ttl). See Signed URLs for the browser-side handling.

Admin tokens (lwadm_) and RBAC

The Admin API authenticates with an admin API token, distinct from access keys. It is a high-entropy bearer secret minted offline on the server host (there is no JSON route to mint the first token; the bootstrap is filesystem-trust only) and stored hashed (SHA-256), never in plaintext:

sh
lockwell admin-token create --role operator             # cluster-wide operator
lockwell admin-token create --role viewer --tenant acme # read-only, one tenant

Authorization composes the RBAC role with an optional single-tenant scope:

RoleCan
ownerEverything, including admin-token and admin-user management
operatorTenant/key/quota lifecycle and audit (no admin-user management)
viewerRead-only (list/get tenants, keys, quota, usage, audit)

A tenant-scoped token is forced to its own tenant server-side. A request targeting another tenant is a 403, never a 404 existence leak. Bearer tokens are not sent automatically by browsers, so the Admin API is server-to-server and carries no CSRF flow. Every request, success and denial, is audited.

ts
import { AdminClient } from "@kelphect/sdk";

const admin = new AdminClient({
  endpoint: "http://localhost:9001", // admin listener, not the public S3 port
  token: process.env.LOCKWELL_ADMIN_TOKEN, // Authorization: Bearer lwadm_...
});
await admin.listTenants();

Keep the admin token server-side only. Provisioning runs in your backend. The browser never sees an admin token or an access-key secret, only short-lived signed URLs.

How an app maps customers to tenants

A typical multi-tenant backend:

  1. On customer onboarding, call provisionTenant(<your customer id>) once. Store the returned { accessKeyId, secretKey } against that customer, encrypted.
  2. On each request, look up the customer's creds and get a per-tenant native client (clientForTenant). It is cached per (tenant, creds) and reuses one token manager.
  3. For browser I/O, mint a short-lived signed URL and return only the URL.
  4. The customer id in your URL paths is a lookup key into your store. It is never a cross-tenant vector on Lockwell, because the acting tenant is always derived from the stored creds' token.

The runnable Go service example models exactly this shape. See also App kit.

Security posture

Tenant isolation comes from the credential

The tenant is always taken from the signed credential, never from the request path, so cross-tenant access is structurally impossible. Another tenant's bucket simply does not exist for your token (returned as 404, never a leak). The same holds across all three surfaces.

Always-on, fail-closed

  • Encryption at rest is always on. Every object, written through any surface, is encrypted, deduped, quota-checked, and retention-gated through the same object pipeline.
  • Constant-time comparisons guard token HMACs and webhook signatures. Secrets and live tokens are never logged or rendered.
  • Fail-closed by default. Anonymous and unauthenticated requests, and revoked or expired credentials, are denied (401). A delete blocked by retention or a legal hold returns 412. A tenant over quota returns 507.

Deliberate non-goals

To keep the trust surface small, Lockwell deliberately does not offer:

  • No public or anonymous buckets. There is no access without a token or a signed URL.
  • No SSE-KMS / SSE-C. Encryption at rest is server-managed (SSE-S3-style).
  • No IAM/STS. There is no policy/role/temporary-credential service. Scoping is done with the key scope grammar above.
  • Webhook-only notifications. SNS/SQS/Lambda targets return 501. See Webhooks.

Next steps

Released under the Apache-2.0 License. License