ADR-025: Identity, Auth & Secrets
Decision
Authentication is JWT-based and RS256-signed, reusable across all applications: short-lived in-memory access tokens and rotating httpOnly refresh tokens carrying a canonical, PII-free claim set. Secrets are read from config at startup, never logged, and never passed as bare arguments through business logic.
Key rules
- Algorithm is RS256 (asymmetric). HS256 is forbidden for multi-app use — sharing the symmetric secret defeats the purpose.
- Access token: 15-minute lifetime, stored in memory only. Never
localStorage. - Refresh token: 7-day lifetime,
httpOnly+Secure+SameSitecookie, rotated on every use. - Required claims:
iss,aud,sub,tenant_id,roles,iat,exp,jti. - Verify signature,
exp,iss, andaudon every request — any failure returns401with no partial trust. Role checks that fail return403. - Never put PII (email, name, phone) in token claims; use opaque IDs only.
- The RS256 private signing key is a secret governed by the rules below — read from config at startup, never logged.
- Never log: API keys, tokens, passwords, HMAC secrets, PII (email/name/phone/IP), card numbers, session tokens, and request/response bodies larger than 2 KB.
- Always-opaque identity fields:
user_id,tenant_id, andrequest_idare ULIDs — never raw email or display name. - Inject the full
Configinto provider factories. Do not pass secret values as function arguments. - Secrets are read from config once at startup, never accessed inline in business logic.
gitleaksandgitleakssecret scanning run on every PR as a blocking check.
Code patterns
// Canonical claim set — opaque IDs only, no PII{ iss, aud, sub: "user_01H...", tenant_id: "org_01H...", roles: ["admin"], iat, exp, jti: "tok_01H..."}
// App usageconst claims = await auth.verifyAccessToken(bearer); // 401 on bad tokenauth.requireRole("admin"); // 403 if not admin// Config fields for secret values — read once at startup, never loggedtype Config struct { JWTPrivateKey string `mapstructure:"JWT_PRIVATE_KEY"` StripeKey string `mapstructure:"STRIPE_SECRET_KEY"` WebhookSecret string `mapstructure:"WEBHOOK_SECRET"`}Why
A single asymmetric (RS256) JWT standard lets every application verify tokens with the public key while only the issuer holds the private key, so identity is reusable without sharing a secret (RFC 7519, RFC 7518). Short in-memory access tokens plus rotating httpOnly refresh tokens limit the blast radius of theft, and an opaque, PII-free claim set keeps sensitive data out of tokens and logs. Treating the signing key as just another config-loaded secret unifies auth and secrets handling under one never-log discipline, which is why this record folds the former ADR-025 secrets rules in rather than leaving them separate.
This record consolidates the auth standard with the secrets-management rules and reconciles two open items from the superseded ADR-025: its key-storage, webhook-HMAC, and rotation manual-gate question is resolved here to the config-at-startup model, and its “Superseded by ADR-072” note is set aside — current practice routes secret handling through this consolidated record.
Applies when
You are implementing any login, signup, session, or token flow; building or reviewing token-minting or verification code; or handling any API key, webhook secret, credential, token, or other sensitive configuration value.
Related
- ADR-027 — default tech stack (auth defaults).
- ADR-029 — multi-tenancy (
tenant_idclaim usage). - ADR-024 — engineering standards (wide-events never-log list alignment).