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
| Concept | What it is | Issued by | Used for |
|---|---|---|---|
| Tenant | An isolated namespace (your "org") | Admin API / app kit | Containing one customer's buckets and objects |
| Access key | accessKeyId + secretKey, scoped to a tenant | Admin API / app kit | S3 SigV4, and minting native tokens |
Native bearer token (lwtk_) | Short-lived token minted from an access key | The native client (auto) | The native JSON data plane |
| Native signed URL | A method/resource/TTL-bound URL, no token needed | A native client | Browser-direct upload/download |
Admin token (lwadm_) | Bearer token with an RBAC role | lockwell admin-token create | The 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.
const { tenantId, created, key } = await kit.provisionTenant("acme", {
name: "Acme Inc",
defaultBucket: "inbox",
});res, err := kit.ProvisionTenant(ctx, "acme", lockwellkit.ProvisionTenantInput{
Name: "Acme Inc",
DefaultBucket: "inbox",
})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.
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 prefixThe 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,adminapplied to the whole tenant. Example:read,write. - Qualified form.
op=<verbs>:bucket=<bucket>:prefix=<prefix>wherebucketandprefixnarrow the verbs.prefixrequiresbucket. Example:op=read,write:bucket=uploads:prefix=tenants/acme/.
Which verb each operation needs:
| Operation | Verb |
|---|---|
| GET, HEAD, list objects/versions, get tags/retention/legal-hold | read |
| PUT, copy, multipart, set tags, set retention/legal-hold | write |
| delete object, batch delete, abort multipart | delete |
| create/delete bucket, set versioning, set notifications | admin |
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.
// 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" });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"})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
accessKeyIdand secret both change and the response reportsoldAccessKeyId. 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:
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
401even 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:
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 });up, _ := kit.SignedUploadURL(ctx, creds, "inbox", "photo.jpg",
lockwellkit.SignedUploadURLInput{TTLSeconds: 300, ContentType: "image/jpeg"})
down, _ := kit.SignedDownloadURL(ctx, creds, "inbox", "photo.jpg", 300)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:
lockwell admin-token create --role operator # cluster-wide operator
lockwell admin-token create --role viewer --tenant acme # read-only, one tenantAuthorization composes the RBAC role with an optional single-tenant scope:
| Role | Can |
|---|---|
owner | Everything, including admin-token and admin-user management |
operator | Tenant/key/quota lifecycle and audit (no admin-user management) |
viewer | Read-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.
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:
- On customer onboarding, call
provisionTenant(<your customer id>)once. Store the returned{ accessKeyId, secretKey }against that customer, encrypted. - 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. - For browser I/O, mint a short-lived signed URL and return only the URL.
- 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 returns412. A tenant over quota returns507.
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
- The three surfaces. Where each credential applies.
- Signed URLs and Webhooks. Browser-direct and event paths.
- Admin API reference. The full tenant/key/quota/audit surface.