Skip to content

Authentication

Every request authenticates with an Authorization: Bearer <credential> header. Three credential types are accepted:

  1. 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.
  2. 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.
  3. 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.

GoalScopeExample
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 + writekv:write... --scope 'kv:write'
A few services, mixedleaderboard: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 on every service path with 403 credential carries 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:read only reaches that service's paths. On any other path it fails with 403 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. Tenant-scoped SQL reads and writes stay within that tenant, and Redis keys carry its namespace. A token never gains access to another tenant by carrying broader service scopes.

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. The issuer is the server's PUBLIC_API_URL (it derives iss from it, and the OpenID discovery document is served from that URL), and the audience defaults to snug-api; both must match the server's configuration. The claims that drive authorization:

ClaimPurpose
azpTenant ID (see above)
scopeSpace-separated service:level pairs; absent = full access
platform_roleGlobal role; admin unlocks /admin/* routes. Missing reads as user
org_roleRole in the active organization (owner, admin, member); present on org sessions
capabilitiesFunctional grants beyond the role (e.g. moderator)
email_verifiedService 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 401 Invalid token signature. Generate a matching pair with snug jwt keygen. The issuer comes from --issuer, JWT_ISSUER, or PUBLIC_API_URL; set PUBLIC_API_URL to the same value the server uses, or the token is rejected with 401 Unknown token issuer.

# 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=expired

Troubleshooting

ResponseCauseFix
401 Missing Authorization header with Bearer tokenNo credentialAdd the Bearer header
401 Token has expiredexp in the pastMint a new token
401 tenant is not registeredazp not in the registryUse a tenant minted by signup or org creation
401 Invalid token signature, 401 Unknown token issuer: ..., 401 Invalid token audienceKeypair, issuer, or audience mismatchKeypair must match the server's; issuer is the server's PUBLIC_API_URL; audience snug-api
403 EMAIL_NOT_VERIFIEDSession of an unverified accountVerify the email; on a local server without real email, mint tokens with snug jwt generate instead - they skip this gate
403 insufficient scopeNamed scopes don't cover this pathWiden the scope or use a wildcard
403 credential carries no scopesAn API key or delegated-client token created with an empty scope list - service paths refuse it, though it can still read /api/v1/auth/meRecreate the credential with explicit scopes
403 Admin access requiredPlatform role is not adminUse an admin token

Operators tune the auth service through AUTH_SERVICE_* environment variables: token and session lifetimes, password policy, the email provider, the redirect allowlist, and WebAuthn.