Guides

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 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: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: 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:

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

Troubleshooting

Every failure below was reproduced against a live server.

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 with a generic messageSignature, issuer, or audience mismatchKeypair must match the server's; check issuer/audience
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

Auth service configuration (AUTH_SERVICE_*) is documented in the auth CONFIG reference.

On this page