Authentication
Authentication covers customers created at checkout and the photographer's administrator account. Visitors are unauthenticated. Gallery and payment rules are authorization decisions and are specified separately.
The API uses an access token and a refresh token. It does not use a session cookie.
Passwords
Passwords are hashed with Argon2id. The baseline is the OWASP password-storage recommendation current at implementation: 19 MiB of memory, 2 iterations, and parallelism 1. Raise memory if the host can spare it. The hash lives in user_credentials.password_hash. The plaintext password is not logged, stored, or put in an audit payload.
Access token
Login, account setup, and refresh return a signed access token.
- Format: JWT, HS256, signed with
JWT_ACCESS_SECRET. - Lifetime: 15 minutes.
- Claims:
subis the user id, plusrole. - The client sends it as
Authorization: Bearer <accessToken>. - The token is not written to PostgreSQL. When it expires, the client calls the refresh route.
Refresh token
- Generate 32 random bytes and return them once in the JSON body.
- Store SHA-256 of those bytes in
refresh_tokens.token_hash. - Set
expires_atto 14 days.
POST /auth/refresh accepts the refresh token, rejects it when the hash is missing, revoked, or expired, revokes that row, inserts a new refresh-token hash, and returns a new access token and a new refresh token. Reuse of a revoked refresh token revokes the user's outstanding refresh tokens.
Logout revokes the presented refresh token by setting revoked_at. Password reset revokes every refresh token for that user.
The browser does not attach these tokens automatically, so cookie CSRF protection is not used. Stripe webhooks authenticate with the Stripe signature, not with either token.
Account setup
After a verified payment for a PENDING_SETUP user:
- Generate 32 random bytes and store only the SHA-256 hash.
- Set
expires_atto 72 hours. - Keep
used_atnull. The partial unique index allows one unused token per user. Issuing a new token marks the previous unused token used. - Email the raw token once, inside a link to the Next.js setup page.
- On submit, hash the presented token, lock the row, reject it when it is missing, used, or expired, set the Argon2id password, set
used_at, set the user toACTIVE, create a referral code, and return an access token and a refresh token.
A second submit finds used_at set and fails.
Existing active customers do not receive a setup token.
Password recovery
password_reset_tokens uses the same hash and single-use rules. The initial lifetime is 1 hour. A successful reset updates password_changed_at and revokes every refresh token for that user.
The API response for an unknown email matches the response for a known email, so the endpoint does not reveal which addresses exist.
Administrator MFA
Production administrative routes require an enabled TOTP factor in user_mfa_factors. The shared secret is stored as ciphertext in secret_ciphertext. Enrollment shows the secret once, then requires a valid code before enabled_at is set. Login for an administrator is password plus a current TOTP code. The access token is issued only after that check succeeds. Recovery codes, if added, are stored hashed and single-use.
Customer accounts do not require MFA in this edition.
Rate limits
Initial defaults, enforced at Express:
| Route | Limit |
|---|---|
| Login and refresh | 10 attempts per minute per IP and per email. |
| Account setup and password reset | 5 requests per hour per email. |
| Checkout creation | 10 requests per hour per user or email. |
Limits are a starting point. Tune them from production logs without changing the authentication rules.