Authentication
Every request authenticates with an Authorization: Bearer <credential>
header. Three credential types are accepted:
- SnugNut JWTs - RS256 tokens signed with the server's key, issued by
the built-in auth service (signup/signin) or minted locally with
snug jwt generate. - External provider JWTs - tokens from a registered external identity provider, validated against the provider's JWKS. On first use a local shadow user is created.
- API keys - opaque
snug_key_...strings, passed in the same Bearer header. The prefix routes them to key validation automatically.
Health (/health), metrics (/metrics), and the public auth flows (signup,
signin, refresh, and friends under /auth/*) need no credential. Everything
else does.
Picking a credential
scope is a space-separated list of service:level pairs. Omitting it means
full access - the right default for first-party users and workers. write
implies read, and service:* equals service:write.
| Goal | Scope | Example |
|---|---|---|
| Full access for a person | (omit) | snug jwt generate user -u alice -t <tenant> |
| Full access for a worker | (omit) | snug jwt generate service -s worker -t <tenant> |
| Platform admin | (omit) + role | ... --platform-role admin |
| Read-only, everything | *:read | ... --scope '*:read' |
| One service, read + write | kv:write | ... --scope 'kv:write' |
| A few services, mixed | leaderboard:read timeline:write | ... --scope 'leaderboard:read timeline:write' |
| Full-access API key | * | API keys must carry an explicit scope |
Three gotchas, all enforced:
- An API key created with an empty scope list is denied everywhere with
403 API key has no scopes- keys must be explicit. - A bare scope without a colon (
read,admin) grants nothing; it is never promoted to full access. - A named scope like
kv:readonly reaches that service's paths. On any other path it fails with403 insufficient scope; only a global wildcard (*,*:read, ...) passes everywhere.
Tenants: the closed world
The azp claim carries the tenant ID, and it is the primary isolation
boundary: every tenant-scoped key the request touches is prefixed with it,
so one tenant never sees another's data. Scope never widens tenancy - a
token only ever sees its own tenant.
The tenant registry is a closed world. Tenants are registered by:
- Signup - your user ID becomes your personal tenant
- Organization creation - the org ID becomes an org tenant
- External identity provisioning - shadow users register on first login
A token whose azp is missing or unregistered is rejected with 401 before
any handler runs. There is deliberately no API that registers an arbitrary
tenant name.
Token structure
Required claims: iss, sub, aud, exp, iat - issuer and audience
default to snug-api and must match the server's configuration. The claims
that drive authorization:
| Claim | Purpose |
|---|---|
azp | Tenant ID (see above) |
scope | Space-separated service:level pairs; absent = full access |
platform_role | Global role; admin unlocks /admin/* routes. Missing reads as user |
org_role | Role in the active organization (owner, admin, member); present on org sessions |
capabilities | Functional grants beyond the role (e.g. moderator) |
email_verified | Service APIs require this to be true; snug jwt generate sets it, session tokens reflect the account |
An organization admin is not a platform admin - the two travel in separate claims and unlock different surfaces.
The built-in auth service
A complete identity stack ships with the server: email/password signup and signin, magic links, OAuth providers, passkeys, session management with refresh rotation, organizations with Owner/Admin/Member roles and invitations, TOTP two-factor, API keys, enterprise SSO with DNS-verified domains and enforcement, and SCIM 2.0 provisioning. Routes split across three prefixes:
- Public (no credential):
/auth/*- signup, signin, refresh, magic links, OAuth/SSO callbacks - Authenticated:
/api/v1/auth/*- sessions, profile, organizations, API keys, 2FA - Admin:
/admin/auth/*- user search, ban, impersonation, SSO enforcement
The server is also an OAuth 2.1 authorization server: discovery lives at
/.well-known/openid-configuration and /.well-known/jwks.json, with
dynamic client registration and a PKCE authorization flow under /oauth/*.
SCIM is mounted at /scim/v2 and requires a provider-bound API key with
scim:read/scim:write scopes - wildcard scopes deliberately do not reach
it. The full endpoint contracts are in the
API reference.
Minting tokens locally
snug jwt generate signs tokens with JWT_PRIVATE_KEY and never contacts
the server - if that key does not match the server's JWT_PUBLIC_KEY,
requests fail with a generic 401. Generate a matching pair with
snug jwt keygen.
# Full-access user token, 1 hour
snug jwt generate user -u alice -t <tenant> --format compact
# Scoped service token, 1 day
snug jwt generate service -s worker -t <tenant> --scope 'job_queue:write pubsub:read' -e 1d
# Inspect and verify
snug jwt decode "$SNUG_API_TOKEN"
snug jwt verify "$SNUG_API_TOKEN" # exit 0=valid, 1=invalid, 2=expiredTroubleshooting
Every failure below was reproduced against a live server.
| Response | Cause | Fix |
|---|---|---|
401 Missing Authorization header with Bearer token | No credential | Add the Bearer header |
401 Token has expired | exp in the past | Mint a new token |
401 tenant is not registered | azp not in the registry | Use a tenant minted by signup or org creation |
401 with a generic message | Signature, issuer, or audience mismatch | Keypair must match the server's; check issuer/audience |
403 EMAIL_NOT_VERIFIED | Session of an unverified account | Verify the email; on a local server without real email, mint tokens with snug jwt generate instead - they skip this gate |
403 insufficient scope | Named scopes don't cover this path | Widen the scope or use a wildcard |
403 credential carries no scopes | An API key or delegated-client token created with an empty scope list - service paths refuse it, though it can still read /api/v1/auth/me | Recreate the credential with explicit scopes |
403 Admin access required | Platform role is not admin | Use an admin token |
Auth service configuration (AUTH_SERVICE_*) is documented in the
auth CONFIG reference.