Authentication Flows
This document specifies the core authentication flows. MFA-specific flows (TOTP/SMS/email OTP, recovery, step-up) live in mfa-otp.md; the endpoint contracts are in api-reference.md.
All flows obey two cross-cutting rules:
- No user enumeration. Endpoints that take an email (register, login, forgot password, resend verification) return an identical generic response whether or not the account exists. Differences are surfaced only over authenticated channels.
- Rate limited. Every unauthenticated mutating endpoint is wrapped by the
framework
RateLimitMiddlewarekeyed by both IP and (where known) account. See security.md.
1. Registration
Section titled “1. Registration”Client Polaris │ POST /auth/register │ │ {email, password, │ │ display_name?} │ ├───────────────────────────►│ 1. Validate input (Input DTO rules) │ │ 2. Normalize email (lowercase/trim) │ │ 3. Enforce password policy + breach check │ │ 4. If email exists → still return 202 generic │ │ 5. Create user (status=active, │ │ email_verified_at=null, │ │ password_hash=argon2id) │ │ 6. Create email-verification challenge │ │ 7. Emit user.registered → mailer sends OTP/link │ 202 Accepted (generic) │ │◄───────────────────────────┤- Password policy is enforced before hashing: min length (default 12), not
in the breached-password set (optional
BreachedPasswordCheckInterface, e.g. HIBP k-anonymity; default no-op). Returns422with a problem detail listing failed rules; this is not enumeration (it’s about the submitted password). - Hashing:
password_hash($pw, PASSWORD_ARGON2ID)via aPasswordHasherInterfaceport so the algorithm/cost is configurable and swappable. Verification usespassword_verify(matches the framework’sRepositoryIdentityValidator) and rehashes on login whenpassword_needs_rehash()reports an outdated cost. - Registration does not create an organization. Org creation is a separate, authenticated step (or an invitation acceptance); see rbac.md.
- An unverified user may log in (configurable:
flows.require_verified_email), but until verified receives a token whoseemail_verifiedclaim isfalse; the host can gate features on it. Default: verification required before the first full login → login returns403 email_unverifiedwith a resend hint.
2. Email verification
Section titled “2. Email verification”Two interchangeable styles (config flows.email_verification.style):
link(default): the email contains…/verify-email?token=<opaque>. The client posts the token toPOST /auth/email/verify. The token is the raw random value; only its HMAC hash is stored (auth_email_verifications).otp: the email contains a 6-digit code; the client posts{email, code}. Backed byauth_otp_challenges(purpose=email_verify).
On success: set email_verified_at, consume the challenge, emit
user.email_verified. Idempotent: re-verifying an already-verified email
returns 200 without error.
POST /auth/email/verify/resend always returns 202 generic; internally it
rate-limits per account and reissues a challenge only if the email is unverified.
3. Login
Section titled “3. Login”Client Polaris │ POST /auth/login │ │ {email, password} │ ├──────────────────────────────►│ 1. Lookup user by normalized email │ │ 2. Constant-time password_verify │ │ (run a dummy verify when user missing, │ │ to equalize timing, anti-enumeration) │ │ 3. Check status (disabled/locked) + lockout │ │ 4. On failure: increment failed_login_count, │ │ maybe lock, emit user.login_failed → 401 │ │ 5. On success: reset failed count, │ │ rehash if needed, emit user.logged_in │ │ 6. MFA gate (see below)MFA gate:
- If the user has no confirmed MFA factors and MFA is not enforced → issue a full token pair (step 7 below).
- If the user has confirmed factors (or
mfa_enforced/global enforce) → respond200with anmfa_requiredbody: a short-livedmfa_tokenJWT (purpose=login_mfa, ~5 min) plus the list of available factors (masked destinations). The client then completes MFA via/auth/mfa/challenge+/auth/mfa/verify(see mfa-otp.md). Only on successful MFA does Polaris mint the real token pair.
Token issuance (step 7), on full success:
- Determine active org: the user’s last-used org, else their only org, else
null(no org context yet). Carried as theorgclaim. - Resolve roles/permissions for that org (see rbac.md).
- Mint access JWT (
LcobucciTokenGenerator); claims in §6. - Mint refresh token: 256-bit CSPRNG opaque secret; store its HMAC hash in
auth_refresh_tokenswith a freshfamily_id, the active org, device UA/IP, andexpires_at = now + refresh_ttl. - Return both. The refresh token’s plaintext is returned once and never stored server-side.
Response body:
{ "data": { "access_token": "<jwt>", "token_type": "Bearer", "expires_in": 900, "refresh_token": "<opaque>", "user": { "id": "…", "email": "…", "email_verified": true }, "active_org": { "id": "…", "slug": "…", "roles": ["owner"] } }}Delivery options. By default tokens are returned in the JSON body for Bearer-header use (no CSRF surface). A host may opt into
flows.token_delivery: cookie, which sets the refresh token as aHttpOnly; Secure; SameSite=Strictcookie and engages the frameworkCsrfMiddlewarefor cookie-authenticated mutations. The access token stays a short-lived bearer value. See security.md.
4. Authenticated request validation
Section titled “4. Authenticated request validation”Protected routes sit behind the framework’s TokenAuthenticationMiddleware,
wired with Polaris’s TokenFactory/TokenParser/TokenValidator:
HeaderTokenExtractorpulls theAuthorization: Bearer <jwt>.- Polaris’s
TokenParser(wrappingLcobucciTokenParser) verifies signature (against the configured public key / JWKS bykid),exp,nbf,iss,aud. - The validated
TokenInterfaceis attached to the request (TokenInterface::TOKEN_KEY). Its metadata exposessub,org,roles,mfa,auth_time,jti,sid. - Authorization (permission checks) runs in a separate
AuthorizationMiddlewaredownstream; see rbac.md.
Access-token validation is stateless (no DB hit) for throughput. Revocation
is handled at the refresh boundary (short access TTL bounds the blast radius);
hosts needing instant access-token kill can enable an optional jti denylist
cache (security.access_token.denylist: true).
5. Token refresh & rotation
Section titled “5. Token refresh & rotation”Client Polaris │ POST /auth/token/refresh │ │ {refresh_token} │ ├──────────────────────────────►│ 1. HMAC-hash → lookup auth_refresh_tokens │ │ 2. Not found → 401 invalid_grant │ │ 3. Found & revoked → REUSE DETECTED: │ │ revoke whole family_id, │ │ emit auth.refresh_reuse_detected → 401 │ │ 4. Found & expired → 401 invalid_grant │ │ 5. Valid: revoke current (reason=rotated), │ │ mint new refresh in SAME family_id with │ │ parent_id=current.id, │ │ mint new access JWT, │ │ emit auth.token_refreshed │ 200 {access, refresh} │ │◄──────────────────────────────┤- Rotation: every refresh consumes the presented token and issues a new one in the same family. A leaked-then-used old token is detected at step 3.
- Sliding vs absolute: refresh lifetime is absolute by default
(
expires_atfixed at login). Optional sliding mode (refresh_token.sliding: true) extendsexpires_aton each rotation up to a hard cap (refresh_token.max_lifetime). - The new access token re-resolves roles/permissions, so permission changes take effect within one access-TTL window without forcing re-login.
6. Access-token claims
Section titled “6. Access-token claims”Minted by LcobucciTokenGenerator (asymmetric, RS256 or EdDSA):
| Claim | Meaning |
|---|---|
iss | configured issuer |
aud | configured audience (resource servers) |
sub | user id (uuid) |
iat / exp | issued-at / expiry (TTL default 900s) |
nbf | not-before |
jti | unique token id (for optional denylist / audit correlation) |
sid | session = refresh family_id (ties access to a device) |
org | active organization id (nullable) |
roles | role slugs in the active org, e.g. ["admin"] |
scope | flattened permission keys (optional; off by default to keep tokens small) |
email_verified | bool |
mfa | bool; whether this session satisfied MFA |
amr | auth methods, e.g. ["pwd","otp"] |
auth_time | unix ts of the last full authentication (for step-up) |
Header carries kid for key rotation; verifiers fetch the matching public key
from the JWKS endpoint.
7. Sessions & logout
Section titled “7. Sessions & logout”GET /auth/sessions: lists the user’s active (non-revoked, non-expired) refresh tokens as devices: id, masked UA, IP,created_at,last_used_at, andcurrent: truefor the calling session (matched bysid).DELETE /auth/sessions/{id}: revoke a specific session (revoked_reason=admin/user); the device’s next refresh fails.POST /auth/logout: revoke the current session (bysid/refresh). Stateless access tokens remain valid untilexp; enable thejtidenylist for immediate cutoff.POST /auth/logout-all: revoke every session for the user (e.g. after a security scare). Emitsauth.sessions_revoked.
8. Password reset (forgot) & change
Section titled “8. Password reset (forgot) & change”Forgot (unauthenticated):
POST /auth/password/forgot {email} → 202 generic (always) → if user exists & active: create reset challenge (1h TTL), emit user.password_reset_requested → mailer sends link/OTPReset (with token/OTP):
POST /auth/password/reset {token | (email,code), new_password} → validate challenge (unconsumed, unexpired, attempts ok) → enforce password policy + breach check → set new password_hash, consume challenge → revoke ALL refresh tokens (reason=password_change) ← logout everywhere → emit user.password_changed → 200Change (authenticated, step-up required):
POST /auth/password/change {current_password, new_password} → require recent MFA/step-up if the user has MFA (auth_time freshness) → verify current_password → enforce policy, set new hash → revoke all OTHER sessions (keep current), reason=password_change → emit user.password_changedResetting/changing a password always invalidates other sessions, a core account-takeover containment measure.
9. Org switching
Section titled “9. Org switching”A user in multiple orgs operates in one active org at a time (the org
claim). POST /auth/switch-org {organization_id}:
- Verify the user has an
activemembership in the target org → else403. - Re-resolve roles/permissions for that org.
- Mint a new access token scoped to the new org. The refresh token’s
organization_idis updated to keep subsequent refreshes scoped correctly. - Emit
auth.org_switched.
Returns a fresh access token (and refresh if rotated). The client swaps its bearer token and continues.
10. Account status & lockout
Section titled “10. Account status & lockout”status=disabled(admin action) → all auth attempts return403; existing sessions are revoked.- Lockout: after
lockout.max_attemptsfailed logins withinlockout.window, setlocked_until = now + lockout.lock_durationandstatus=locked; emituser.locked. Locked logins return a generic401(no “your account is locked” leak unless authenticated). Auto-unlocks whenlocked_untilpasses; a successful login resets the counter. - Lockout is per-account; IP-based throttling (separate, via rate limiter) defends the broader surface so a single attacker can’t lock many accounts as a DoS; see security.md.