Security model
What you'll learn: who is allowed to do what, and how OpenBucket proves it on every request. There are two planes — the S3 data plane (SigV4) and the admin plane (JWT) — and they never share credentials.
The two planes at a glance
S3 clients ──► SigV4Guard ──► PolicyAuthorizationGuard ──► S3 handler
(AWS SDK) verify sig bucket policy AND key scope
Admin console ─► JwtAuthGuard ──► RolesGuard ──────────────► /api/admin/* handler
(browser) verify JWT full-admin vs read-only
- Data plane — any S3 client authenticates with AWS Signature V4 using an access-key pair. Requests are then authorized by bucket policy and the key's scope.
- Admin plane — the console and CLI authenticate with a short-lived JWT minted from an argon2id password check, and are authorized by an admin role.
SigV4 for the S3 API
Every S3 request is verified by SigV4Guard before any handler runs. It supports
both signing forms AWS SDKs use:
- Header-signed requests (
Authorization: AWS4-HMAC-SHA256 …). - Presigned URLs (the
X-Amz-Signaturequery parameter).
The guard:
- Reverse-computes the signature from the resolved secret and compares it in
constant time (
crypto.timingSafeEqual) — a mismatch is a genericSignatureDoesNotMatch, so a wrong secret and a wrong key look identical. - Enforces a ±15-minute clock skew on
X-Amz-Date(RequestTimeTooSkewedbeyond that), the AWS default. - Requires
host(and any wire-presentx-amz-*header) to be signed, so those headers can't be left unbound by a craftedSignedHeaderslist. - Accepts the streaming-upload forms
STREAMING-AWS4-HMAC-SHA256-PAYLOAD(signed chunks) andSTREAMING-UNSIGNED-PAYLOAD-TRAILER; the signed trailing-checksum form (STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER) is rejected with a clearInvalidArgument.
OpenBucket addresses buckets path-style (host/bucket/key), never
virtual-host style. Configure your SDK with forcePathStyle: true and a region
that matches OPENBUCKET_REGION (default us-east-1).
The root key vs scoped sub-keys
The root credential (ROOT_ACCESS_KEY_ID / ROOT_SECRET_ACCESS_KEY) is
loaded from the environment at boot, cached in memory, never persisted, and is
always unrestricted.
On top of it you mint scoped sub-keys — full SigV4 access keys confined to a
bucket + key-prefix (or an inline policy) — via POST /api/admin/keys:
const { data } = await keysApi.createKey({
label: 'tenant-a-uploader',
scope: { kind: 'prefix', bucket: 'tenants', prefix: 'tenant-a/' },
});
// data.accessKeyId / data.secretAccessKey — returned ONCE; hand them to the tenant.
A key minted with a scope records role: 'scoped'; without one it records
role: 'root' (unscoped, root-equivalent).
How a request gets authorized
After SigV4 proves who you are, PolicyAuthorizationGuard decides what you
may do. The scope you gave a sub-key is compiled once, at mint time, into the same
IAM-style PolicyDocument the bucket-policy evaluator already understands. The
effective decision is bucket policy AND scope:
- The root key is evaluated with
defaultAllow: true— a bucket policy without a matching statement leaves root's access intact; only an explicitDenyblocks it. - A scoped key is evaluated with implicit deny (
defaultAllow: false) — an action or resource the scope doesn'tAllowis denied even when the bucket has no policy. - An explicit bucket-policy
Denyalways wins (checked first, never masked). - A prefix scope grants
s3:ListBucketonly under aStringLike s3:prefixcondition, so a tenant key can't enumerate the whole bucket with an unprefixedListObjectsV2. - A scoped key calling a service-level op (
ListBuckets) is denied unless its scope explicitly allowss3:ListAllMyBuckets.
The evaluator understands Action/Resource/Principal globs plus two advertised
conditions — Bool aws:SecureTransport and IpAddress/NotIpAddress aws:SourceIp
— and fails closed: an unknown condition operator satisfies a Deny but never
grants an Allow.
Before handing a scoped key to a tenant, call getKeyEffectivePermissions(id) for
an allow/deny matrix, or simulateKeyAction(id, { action, resource }) for a single
decision — both run through the same evaluator the live request path uses, so
the console and production agree.
Admin auth: JWT over argon2id
The admin plane is protected by a global JwtAuthGuard that authenticates every
/api/admin/* request (the S3 and SPA trees pass through untouched).
- Passwords are verified with argon2id. On a username miss, OpenBucket runs a constant-time verify against a dummy hash so login timing never reveals whether a username exists.
- Login mints a 15-minute access JWT plus a rotating refresh token (7-day
default). Both TTLs are configurable (
JWT_ACCESS_TTL_SECONDS,JWT_REFRESH_TTL_SECONDS). - JWTs are verified with a fixed
issuer/audience; the signing key isJWT_SECRET(≥ 32 chars, checked for low entropy and known placeholders at boot).
Roles are read fresh every request
Admins are full admin (admin) or read-only (readonly). A global
RolesGuard is default-deny by HTTP method: it 403s any
POST/PUT/PATCH/DELETE under /api/admin/* for a read-only principal
(except a couple of self-service routes and handlers marked @AllowReadOnly()).
The role is read fresh from the database on every request, not trusted from the JWT claim — so demoting an admin takes effect immediately, even while an old token still verifies. Two anti-lockout invariants always hold: you can't delete or demote the last full admin, and you can't delete your own account.
Secret handling
OpenBucket treats secrets as radioactive:
- Scoped sub-key secrets are encrypted at rest. SigV4 needs the plaintext to
verify a signature, so a sub-key's secret is stored AES-256-GCM encrypted
(
SecretCipher), never plaintext, and decrypted only on the hot path. The GCM auth tag rejects tampering; a tampered blob fails closed (the key is treated as unknown). - The root secret and the SSE key are never persisted as sub-key material — root lives only in memory from the environment.
- Logs are redacted. The pino logger censors
password,secret,secretAccessKey,token,authorization,KEY_ENCRYPTION_SECRET, and friends; SigV4 query-auth parameters (X-Amz-Signature/-Credential/-Security-Token) are stripped from logged URLs so a presigned request never logs a replayable signature. Access-key ids are truncated (AKIA…-xy) when logged. - The audit log strips secret-looking fields before persisting an event, and
drops an oversized
detailpayload — defense in depth on top of a catalogue that never carries secrets.
The reversible key-secret encryption
The 32-byte key-encryption key (KEK) that wraps sub-key secrets is HKDF-SHA256
derived from KEY_ENCRYPTION_SECRET when set, otherwise from
ROOT_SECRET_ACCESS_KEY.
KEY_ENCRYPTION_SECRET up frontIf you rotate ROOT_SECRET_ACCESS_KEY without having set a dedicated
KEY_ENCRYPTION_SECRET, the KEK changes and every existing sub-key secret becomes
undecryptable — those keys must be re-minted. Set a strong KEY_ENCRYPTION_SECRET
(≥ 32 chars) from day one to decouple sub-key storage from the root credential.
At-rest object encryption (SSE-S3)
Objects are optionally encrypted at rest with a single, backend-managed 32-byte
key (the SSE-S3 model) using AES-256-CTR — one key for the whole instance, no
per-object derivation, no in-place rotation in v1. Losing the key
(<DATA_DIR>/sse.key or OPENBUCKET_SSE_KEY) makes every encrypted object
unreadable, so back it up with your other break-glass secrets. See
Durability and Storage layout for how
the ciphertext and its IV are stored and integrity-checked.
Next steps
- Durability — how the bytes you store stay correct over time.
- Storage layout — where secrets and blobs actually live on disk.
- Configuration reference — every auth-related env var and option.
- Architecture — the two-plane picture in context.