Authentication
How a Full preset app signs people up and in, keeps them signed in, and decides what they may do. Implemented in examples/full-single (internal/modules/auth) on the building blocks of modules/auth. Decisions: ADR-0024, ADR-0038, ADR-0043 (two-factor authentication).
The flow#
register ──► email with a 6-digit code ──► verify-email ──► login ──► session (cookie or token)
│
2FA on? ──► 202 challenge ──► login/mfa (code or recovery code)
│
/v1/auth/me, /ops/* (with a role and 2FA), your endpoints
Start the app with aps dev (or docker compose up -d --wait, go run ./cmd/migrate, go run ./cmd/api). Emails land in Mailpit at http://127.0.0.1:8025.
# 1. Create an account
curl -X POST http://127.0.0.1:8080/v1/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"a long enough password"}'
# 2. Read the code in Mailpit, then verify the address
curl -X POST http://127.0.0.1:8080/v1/auth/verify-email \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","code":"123456"}'
# 3. Sign in and keep the token (native clients, scripts)
TOKEN=$(curl -s -X POST http://127.0.0.1:8080/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"a long enough password","transport":"bearer"}' | jq -r .token)
curl http://127.0.0.1:8080/v1/auth/me -H "Authorization: Bearer $TOKEN"
Where the code lives#
Your app owns authentication like any other module, with all four layers. The apistock library only supplies the security building blocks.
| Folder | What's in it |
|---|---|
internal/modules/auth/domain/ |
User, Session, Code, TOTP, MFAChallenge and the module's errors |
internal/modules/auth/usecase/ |
The flows: register.go, verify_email.go, login.go, login_mfa.go, sessions.go (authenticate, sessions, logout), password.go (reset and change), mfa.go (set up, turn off, recovery codes), mfa_admin.go (operator enrollment, reset, key rotation), account.go (delete, create user, roles, cleanup); ports.go lists what they need from storage |
internal/modules/auth/repository/ |
store.go with transactions, and one SQL file per operation: insert_user.go, select_user.go, insert_session.go, use_totp_step.go, use_recovery_code.go, … |
internal/modules/auth/delivery/ |
The /v1/auth endpoints (auth.go, mfa.go) |
internal/app/permissions.go |
Permissions, platform roles, and which roles require two-factor authentication |
internal/app/keys.go |
AUTH_ENCRYPTION_KEYS, which encrypts authenticator app secrets |
internal/app/admin.go, admin_mfa.go |
The grant-role, revoke-role, roles, reset-mfa and rotate-auth-keys commands |
db/migrations/…_auth.sql, …_auth_mfa.sql |
The auth_users, auth_sessions, auth_codes, auth_user_roles, auth_totp, auth_recovery_codes and auth_mfa_challenges tables |
apistock.dev/modules/auth (library) |
Password hashing, tokens, codes, TOTP, the encryption keyring, recovery codes, cookies, the request middleware, the permission catalog, plain emails |
Change a rule, such as allowing only your company's email domain, in the use case (register.go); add a column with a new migration and a repository file.
Your first administrator#
/ops/* needs a platform role, and a session signed in with two-factor authentication. In development, seed data already created one: the first aps dev (or go run ./cmd/seed) creates admin@example.com with platform_admin and two-factor authentication on, and prints its random password, authenticator app key and recovery codes once, without saving them (ADR-0042). Add the key to an authenticator app and sign in as in Two-factor authentication.
To give your own account a role, in development or production, register and verify it as above, then grant the role from the app's directory:
go run ./cmd/api roles # list roles and their permissions
go run ./cmd/api grant-role you@example.com platform_admin # recorded in the audit log as "cli"
The role applies to your next request. platform_admin and ops_viewer require two-factor authentication: until the account turns it on and the session is verified with a second factor, /ops/* answers 403 mfa_required. go run ./cmd/api revoke-role <email> <role> takes a role away.
| Role | Can | Requires 2FA |
|---|---|---|
platform_admin |
Everything under /ops: settings, jobs, audit log, email |
Yes |
ops_viewer |
Read settings, jobs, the audit log and email status; change nothing | Yes |
Add roles and permissions in internal/app/permissions.go; c.RequireMFA("role") makes a role require two-factor authentication. It's code, not a runtime setting, so nobody can switch it off from /ops/settings.
Two-factor authentication#
Any account can turn on two-factor authentication with an authenticator app (TOTP: Google Authenticator, 1Password, Authy and others). Roles that require it can't be used without it.
# 1. Start: send the password, get a secret and an otpauth:// URI (show it as a QR code)
curl -X POST http://127.0.0.1:8080/v1/auth/mfa/totp -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"password":"a long enough password"}'
# 2. Confirm: send a code from the app, get 10 recovery codes (shown once)
curl -X POST http://127.0.0.1:8080/v1/auth/mfa/totp/confirm -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"code":"123456"}'
# Later sign-ins take two steps: the password returns 202 and a challenge...
CHALLENGE=$(curl -s -X POST http://127.0.0.1:8080/v1/auth/login -H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"a long enough password"}' | jq -r .mfa.challenge_token)
# ...and a code (or "recovery_code") finishes it
TOKEN=$(curl -s -X POST http://127.0.0.1:8080/v1/auth/login/mfa -H 'Content-Type: application/json' \
-d "{\"challenge_token\":\"$CHALLENGE\",\"code\":\"123456\",\"transport\":\"bearer\"}" | jq -r .token)
- Confirming turns it on, verifies the current session with a second factor and signs out other devices.
- Each code works once: a code already used, even on another server instance, is refused. Wait for the app's next code.
- A challenge lasts 5 minutes and allows 5 attempts; the attempts count toward the login limit.
- A recovery code works once; the user gets an email saying how many are left.
POST /v1/auth/mfa/recovery-codeswith a code or a passkey response replaces them all (a recovery code can't). - Turning it off (
DELETE /v1/auth/mfa/totp) needs the password and a code, recovery code or passkey response, and isn't allowed while a role requires it. Deleting the account also needs one of them. - Password reset doesn't turn it off: the next sign-in still asks for a code.
- Lost the app and the recovery codes: an operator runs
go run ./cmd/api reset-mfa <email>, which turns it off and signs the account out everywhere.
Encryption keys#
Authenticator app secrets are stored encrypted (AES-256-GCM) with AUTH_ENCRYPTION_KEYS: comma-separated id:base64key entries of 32-byte keys, the first encrypting and all decrypting.
| Environment | What happens |
|---|---|
| Production | Required: the app doesn't start without a valid key. Generate one with echo "k1:$(openssl rand -base64 32)" and keep it in your secret store |
| Development | aps dev writes a random key to .env when it's empty. Without a key (plain go run), two-factor endpoints answer 503 mfa_unavailable, and accounts with it on can't sign in |
To replace a key: put the new key first and keep the old one (k2:…,k1:…) on every instance, run go run ./cmd/api rotate-auth-keys to re-encrypt every secret, then remove the old key. Losing every key turns off everyone's second factor until operators reset it, so back keys up like database credentials.
Passkeys#
Passkeys sign people in with Face ID, Touch ID, Windows Hello, an Android phone or a security key (ADR-0044): with no password at all, or as the second factor after one. A passkey sign-in counts as two-factor authentication, so ops roles work with passkeys alone. What to configure, and for native apps where to find each value: sign-in provider setup.
Add: POST /v1/auth/passkeys/registration → navigator.credentials.create(options) → POST /v1/auth/passkeys
Sign in: POST /v1/auth/passkeys/login/options → navigator.credentials.get(options) → POST /v1/auth/passkeys/login
2nd factor: POST /v1/auth/login (202, methods include passkey) → POST /v1/auth/login/mfa/passkey → get() → POST /v1/auth/login/mfa {"passkey": …}
Confirm: POST /v1/auth/passkeys/verification → get() → DELETE /v1/auth/me, DELETE /v1/auth/mfa/totp or POST /v1/auth/mfa/recovery-codes {"passkey": …}
| Endpoint | Needs a session | Purpose | Success |
|---|---|---|---|
POST /v1/auth/passkeys/registration |
✓ | {password} unless the session verified a second factor in the last 10 minutes; returns ceremony_token and options |
200 |
POST /v1/auth/passkeys |
✓ | {ceremony_token, name?, credential}; recovery codes when it's the first second factor |
201 {passkey, recovery_codes?} |
GET /v1/auth/passkeys |
✓ | The user's passkeys | 200 |
PATCH /v1/auth/passkeys/{id} |
✓ | {name} |
204 |
DELETE /v1/auth/passkeys/{id} |
✓ | {password} unless the session verified a second factor in the last 10 minutes |
204 |
POST /v1/auth/passkeys/verification |
✓ | Options limited to the user's passkeys, to confirm a sensitive change | 200 |
POST /v1/auth/passkeys/login/options |
Start a passwordless sign-in | 200 | |
POST /v1/auth/passkeys/login |
{ceremony_token, credential, transport?} |
200 {user, session, token?} |
|
POST /v1/auth/login/mfa/passkey |
{challenge_token}; options limited to the account's passkeys |
200 |
credentialis the browser'sPublicKeyCredentialas JSON (credential.toJSON());optionsgo toPublicKeyCredential.parseCreationOptionsFromJSONorparseRequestOptionsFromJSON, or a WebAuthn helper library.- Each ceremony works once and lasts 5 minutes. Up to 10 passkeys per account.
- The device must verify the user (biometrics or PIN). A passkey whose signature counter goes backwards is refused and recorded as
auth.passkey.clone_warning. - Adding or removing a passkey needs the password once the session's second factor is 10 minutes old, so a stolen session can't plant its own passkey. With two-factor authentication on, the session must also have verified one.
- The first passkey signs out other devices, like turning on the authenticator app.
- The last second factor can't be removed while a role requires one;
reset-mfaremoves passkeys too. - In development, open the app at
http://localhost:8080: browsers don't allow passkeys on127.0.0.1.
Errors: invalid_passkey (401), passkey_not_found (404), passkey_limit_reached (409), invalid_passkey_name (422), passkeys_unavailable (503, WEBAUTHN_RP_ID not set).
Google and Apple sign-in#
People sign in with their Google or Apple account, in browsers and in native apps (ADR-0046). Each is off until its credentials are set; creating them step by step: sign-in provider setup and the app's AUTH_PROVIDERS.md.
Browser: GET /v1/auth/{google,apple}/start?return_to=… → provider → callback → 303 to return_to (session cookie, #mfa_challenge_token=…, or #error=…)
Native app: POST /v1/auth/{provider}/nonce → SDK sign-in with the nonce → POST /v1/auth/{provider}/token {id_token, nonce}
| Endpoint | Needs a session | Purpose | Success |
|---|---|---|---|
GET /v1/auth/{provider}/start |
?return_to= an absolute URL on the API's origin or APP_CORS_ORIGINS; sets a 10-minute __Host-oauth cookie and redirects |
302 | |
GET /v1/auth/google/callback, POST /v1/auth/apple/callback |
The provider returns here; redirects to return_to |
303 | |
POST /v1/auth/{provider}/nonce |
A single-use nonce for 5 minutes (Apple's iOS SDK takes its SHA-256 in hex) | 200 {nonce, expires_at} |
|
POST /v1/auth/google/token |
{id_token, nonce, transport?} from iOS or Android |
200 session or 202 challenge | |
POST /v1/auth/apple/token |
{id_token, nonce, authorization_code?, name?, transport?} from iOS |
200 session or 202 challenge | |
GET /v1/auth/identities |
✓ | Linked Google and Apple accounts | 200 {identities} |
DELETE /v1/auth/identities/{id} |
✓ | {password} unless the second factor is under 10 minutes old; accounts without a password sign in again first |
204 |
POST /v1/auth/apple/notifications |
Apple's server-to-server notifications | 204 |
- New people get an account with a verified email and no password (
user.has_passwordfalse); they can set one with "forgot password". - An existing account with the same email is linked when the provider has verified the email, and the owner gets an email. If that account never verified its email, its password is removed and its sessions end, so whoever registered the address without owning it loses access.
- Two-factor authentication still applies: an account with it on gets a challenge, as with a password.
- Accounts without a password confirm sensitive changes (authenticator app setup, deleting the account, passkeys, unlinking) with a sign-in less than 10 minutes old.
- The last way to sign in can't be unlinked. Deleting the account unlinks every identity and revokes Apple's tokens; Apple's "consent revoked" notification unlinks Apple too.
Errors: invalid_social_token (401), invalid_state (401), social_email_unverified (403), identity_not_found (404), last_sign_in_method (409), invalid_return_to (422), social_unavailable (503). The web flow puts the same codes in #error=, plus access_denied when the person cancels.
Browsers and native apps#
| Client | Login body | Result | Later requests |
|---|---|---|---|
| Browser (default) | {"email", "password"} |
Set-Cookie: __Host-session=…; Secure; HttpOnly; SameSite=Lax |
The browser sends the cookie; scripts can't read it |
| Mobile, desktop, CLI | {"email", "password", "transport": "bearer"} |
{"token": "…"} in the body, shown once |
Authorization: Bearer <token> |
With two-factor authentication on, send transport to POST /v1/auth/login/mfa instead: that step creates the session.
- Browsers need HTTPS in production (the cookie is
Secure);localhostworks in development. - Cross-site requests carrying the cookie are refused (403), so other websites can't act as your users. Set
APP_CORS_ORIGINSfor your own frontend's origin. - Store native tokens in the platform's secure storage (Keychain, Keystore).
Endpoints#
| Method and path | Needs a session | Purpose | Success |
|---|---|---|---|
POST /v1/auth/register |
Create an account; emails a code | 202 | |
POST /v1/auth/verify-email |
{email, code} |
204 | |
POST /v1/auth/verify-email/resend |
{email}; at most once a minute |
202 | |
POST /v1/auth/login |
{email, password, transport?} |
200 {user, session, token?}, or 202 {mfa: {challenge_token, methods, expires_at}} with 2FA on |
|
POST /v1/auth/login/mfa |
{challenge_token, code, recovery_code or passkey, transport?} |
200 {user, session, token?} |
|
POST /v1/auth/password/forgot |
{email}; emails a reset code |
202 | |
POST /v1/auth/password/reset |
{email, code, password}; signs out every device |
204 | |
GET /v1/auth/me |
✓ | User, current session, permissions, step_up_permissions, mfa_enabled, mfa_required |
200 |
PUT /v1/auth/password |
✓ | {current_password, new_password}; signs out other devices |
204 |
GET /v1/auth/sessions |
✓ | Signed-in devices, current marks this one, mfa_verified |
200 |
DELETE /v1/auth/sessions/{id} |
✓ | Sign out one device | 204 |
POST /v1/auth/logout |
✓ | Sign out this device | 204 |
POST /v1/auth/logout-all |
✓ | Sign out every device | 200 {revoked} |
DELETE /v1/auth/me |
✓ | {password, code, recovery_code or passkey with 2FA}; delete the account |
204 |
POST /v1/auth/mfa/totp |
✓ | {password}; start setting up an authenticator app |
200 {secret, uri, qr_code} |
POST /v1/auth/mfa/totp/confirm |
✓ | {code}; turn two-factor authentication on |
200 {recovery_codes} |
DELETE /v1/auth/mfa/totp |
✓ | {password, code, recovery_code or passkey}; turn it off |
204 |
POST /v1/auth/mfa/recovery-codes |
✓ | {code or passkey}; replace the recovery codes |
200 {recovery_codes} |
What users see#
- Registration always answers "check your email", even if the address already has an account; the owner of an existing account gets an email saying someone tried to sign up.
- Wrong email or password is one answer:
invalid_credentials.email_not_verifiedappears only after the right password. - Forgot password always answers "check your email".
- Codes are 6 digits, expire (15 minutes to verify, 30 to reset, adjustable), allow 5 tries, and a new one replaces the old one.
- Passwords need 12 to 128 characters;
weak_passwordsays what's wrong. - Too many attempts: 10 logins per address per 15 minutes (second factors included), 60 auth requests per minute per IP address;
too_many_attemptssays how long to wait. - Two-factor authentication emails the user when it's turned on or off and when a recovery code is used.
Sessions#
- A session ends after 14 days without use or 90 days in total (runtime settings
auth.session_idle_ttl,auth.session_absolute_ttl), or when signed out. - A session is
mfa_verifiedwhen it was created with a second factor, or when the user confirmed two-factor authentication in it. Roles requiring 2FA grant their permissions only to such sessions. - Changing the password signs out other devices; resetting it or deleting the account signs out every device; turning two-factor authentication on or off signs out other devices.
- The
auth_cleanupjob (daily, 03:30 UTC) removes ended sessions after 7 days, old codes and sign-in challenges, and deleted accounts afterauth.deleted_account_retention(30 days).
Settings#
| Key | Default | Range | Reason required |
|---|---|---|---|
auth.session_idle_ttl |
14 days | 1 hour – 90 days | Yes |
auth.session_absolute_ttl |
90 days | 1 – 365 days | Yes |
auth.verification_code_ttl |
15 minutes | 5 minutes – 1 hour | No |
auth.reset_code_ttl |
30 minutes | 10 minutes – 2 hours | Yes |
auth.deleted_account_retention |
30 days | 1 – 365 days | Yes |
Change them with PUT /ops/settings/{key}. The auth module also enforces hard limits of its own, so no setting can make sessions or codes unsafe. Two-factor authentication has no runtime settings.
In your own code#
Use cases get the signed-in user from the context; the middleware sets it for every request.
import authlib "apistock.dev/modules/auth"
func (s *Service) CreateProject(ctx context.Context, name string) (Project, error) {
switch err := actor.Require(ctx, "projects.create"); {
case errors.Is(err, actor.ErrUnauthenticated):
return Project{}, ErrUnauthenticated
case errors.Is(err, actor.ErrStepUpRequired):
return Project{}, ErrMFARequired // the role requires 2FA and this session hasn't used it
case err != nil:
return Project{}, ErrForbidden
}
p, _ := authlib.PrincipalFrom(ctx) // user, session and permissions, when you need more than the actor
// …
}
- Declare the permission in
internal/app/permissions.goand add it to a role. - Check it in the use case with
actor.Require(oractor.Canwhen you only need yes or no). - Map your module's errors to
unauthenticated(401),mfa_required(403) andforbidden(403) inmodule_<name>.go.
Error codes#
| Code | Status | When |
|---|---|---|
unauthenticated |
401 | No valid session |
forbidden |
403 | Signed in without the permission |
mfa_required |
403 | The permission's role requires two-factor authentication, and this session wasn't verified with a second factor |
invalid_credentials |
401 | Wrong email or password |
email_not_verified |
403 | Right password, address not verified yet |
invalid_email |
422 | Not an email address |
weak_password |
422 | Password fails the policy; detail says why |
invalid_code |
422 | Wrong, used or expired email code |
invalid_mfa |
401 | Wrong or already used second factor, or an expired, used or exhausted sign-in challenge |
mfa_already_enabled |
409 | Setting up two-factor authentication when it's already on |
mfa_not_enabled |
409 | Confirming without starting, or turning off or replacing codes when it's off |
mfa_required_by_role |
409 | Turning two-factor authentication off while a role requires it |
mfa_unavailable |
503 | AUTH_ENCRYPTION_KEYS isn't set on this server |
too_many_attempts |
429 | Rate limited; detail says how long to wait |
session_not_found |
404 | Revoking a session that isn't yours or has ended |
auth_unavailable |
503 | The session store couldn't be reached |
Audit events#
Every sign-in (successful or not), second factor (auth.mfa.challenge_succeeded, auth.mfa.challenge_failed, auth.mfa.recovery_code_used), verification, password change or reset, two-factor change (auth.mfa.totp_enabled, auth.mfa.totp_disabled, auth.mfa.recovery_codes_regenerated, auth.mfa.reset, auth.keys.rotated), sign-out, account deletion and role change is recorded with the client's IP address and user agent. See them with GET /ops/audit?action_prefix=auth.. Email addresses, secrets, codes and recovery codes are never stored in event metadata.
Troubleshooting#
| Symptom | Fix |
|---|---|
| No code arrives | Check Mailpit (http://127.0.0.1:8025) in development, or GET /ops/jobs/runs?kind=apistock.mail.send for delivery errors (email guide) |
email_not_verified |
Verify with the emailed code, or POST /v1/auth/verify-email/resend |
forbidden on /ops/* |
go run ./cmd/api grant-role <email> platform_admin |
mfa_required on /ops/* |
Turn on two-factor authentication (POST /v1/auth/mfa/totp, then /confirm), or sign in again with a code |
invalid_mfa with a correct-looking code |
The code was already used, or the phone's clock is off by more than 30 seconds: wait for the next code |
mfa_unavailable |
Set AUTH_ENCRYPTION_KEYS (aps dev does it in development) |
| Lost authenticator app and recovery codes | go run ./cmd/api reset-mfa <email> |
| Browser isn't kept signed in | Serve over HTTPS (or localhost), and call the API from the same site or an origin in APP_CORS_ORIGINS |
too_many_attempts in tests |
Limits are per address and per IP; use different addresses per test |