# ADR 002: Internal identities and PostgreSQL cookie sessions

## Status

Accepted for Application Zero.

## Decision

Foodtruck Order owns a small global user record and authenticates browsers with an opaque, signed,
HttpOnly cookie whose session data is stored in PostgreSQL. Only a SHA-256 digest of the opaque
identifier is stored. Sessions have an eight-hour absolute lifetime, do not roll, are revocable, and
are rejected when their user is disabled or deleted. Login rotates and deletes the anonymous
identifier. Browser mutations require an exact configured Origin and a plugin-generated CSRF token.

Passwords use Argon2id with 19 MiB memory, two iterations, and parallelism one. The application
normalizes login addresses by trimming and lowercasing for its own uniqueness rule; this does not
claim that every email provider treats address case identically. The common policy permits 15–256
Unicode characters without composition rules, trimming, or truncation.

Password recovery uses 32 random bytes encoded as an opaque URL-fragment credential and stores only
its SHA-256 digest with user, creation, 30-minute expiry, and consumption state. There is no signed
custom token or password-derived reset secret. Issuance keeps at most one row per user and locks the
user while checking credential version. Consumption, password replacement, version increment,
single-use marking, and all-session revocation are one PostgreSQL transaction. Ordinary password
change also deletes outstanding recovery rows. Links are composed only from validated
`APP_PUBLIC_URL`; hosted environments require HTTPS and real configured mail delivery.

## Rationale and consequences

An internal identity is the smallest useful boundary for the current locally operated product. An
OIDC provider would add deployment and recovery dependencies before those needs are established. The
user is deliberately not a tenant, business owner, role, or implicit administrator; later
multi-business work must introduce explicit membership and authorization.

The rate limiter is in-process, so counters reset at restart and are not coordinated across
instances. Expired account-throttle entries are reclaimed on each authentication attempt, so hashed
account keys are not retained for the life of the process; active windows, limits, and per-IP
protection are unchanged. The store remains process-local: limits apply per instance and are not
coordinated across instances. Multi-instance deployments must enforce abuse controls at the shared
or edge layer, or explicitly accept the single-instance constraint — a process-local map must not be
described as global protection. PostgreSQL keeps sessions across backend restarts only when the
signing secret is also stable. Local/test may generate an ephemeral secret for quickstart, which
intentionally logs users out on restart; staging and production require an operator-provided secret
and secure cookies. Expired rows are removed in bounded batches during session writes rather than by
new scheduler infrastructure.

Recovery requests deliberately return the same empty acknowledgement for enabled, unknown, disabled,
and delivery-failure outcomes. A minimum asynchronous padding path and hashed account throttle
reduce enumeration signals, but an in-process limiter and synchronous provider attempt do not
provide strict timing equality or cross-instance coordination. Provider acceptance means only that
the provider accepted the request, not that a message reached an inbox.
