Session and Authentication Design
Status: Approved 2026-09-20. Feeds Plan 2 (Identity and org structure).
Parent spec: 2026-09-20-erp-architecture-design.md —
this document expands §4.2–§4.4 and resolves a contradiction between §4.1 and §4.4.
Where the two disagree, this document wins and the parent is amended to match.
Goal: authenticate a human, prove which organization they are acting in, and make both facts revocable — without reintroducing the spoofable header Phase 0 carried forward.
1. What this replaces
Section titled “1. What this replaces”Phase 0 shipped a deliberate placeholder: Tenant() middleware trusting an
unauthenticated X-Org-Id header, safe only because no route under /v1 touched tenant
data. That placeholder is the blocking dependency recorded in docs/ROADMAP.md, and this
design removes it.
The parent spec described the replacement two ways:
- §4.1 — the org is resolved from “the JWT plus an
X-Org-Idheader, validates membership” - §4.4 — the access JWT already carries
org_id
Resolved: the organization is a signed claim in the access token, and X-Org-Id is
deleted from the codebase. A validated header is correct only for as long as every code
path remembers to validate it; a signed claim is correct by construction. Given that the
failure mode is cross-tenant data disclosure in a multi-tenant financial system, the bug
class is removed rather than guarded.
The cost is accepted: switching organizations is a round trip, and a user active in several organizations holds several access tokens.
2. Identity model
Section titled “2. Identity model”One human is one users row, globally, across every tenant they work in. users
holds credentials and nothing tenant-specific. Everything tenant-visible — membership,
roles, positions, cost centres — hangs off organization_member, which is org-scoped and
fully protected by RLS, exactly as §4.2 describes.
A session family is the login, not the organization. A user authenticates once and may
then act in any organization they are a member of, without re-authenticating. This is why
session_family carries user_id and no org_id.
2.1 Tables
Section titled “2.1 Tables”users ( id UUID PK, email CITEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, email_verified_at TIMESTAMPTZ, totp_secret_enc BYTEA, totp_enabled_at TIMESTAMPTZ, created_at, updated_at)
session_family ( id UUID PK, user_id UUID NOT NULL REFERENCES users, created_at, last_used_at, absolute_expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ, revoked_reason TEXT, device_label TEXT, ip INET, user_agent TEXT)
refresh_token ( id UUID PK, family_id UUID NOT NULL REFERENCES session_family, token_hash BYTEA NOT NULL UNIQUE, issued_at, used_at, expires_at)2.2 The RLS exemption, and the rule that contains it
Section titled “2.2 The RLS exemption, and the rule that contains it”These three tables have no meaningful org_id. Phase 0’s RLS drift test requires one on
every table, and db/rls_exempt.txt currently holds a single entry
(goose_db_version). Adding three exemptions weakens the invariant the whole foundation
rests on, so the exemption is not granted bare. It comes with a second, machine-checked
rule:
An RLS-exempt table may not be referenced by any package under
internal/modules/.
The Phase 0 layering drift test already enforces import direction and is extended to
enforce this. Access to global identity tables is confined to the owning platform package.
db/rls_exempt.txt gains a justification per entry, in the same style as
db/org_id_nullable.txt.
The split is therefore clean: the global login shell is exempt; every tenant-visible identity row is not.
3. Two token types, deliberately different
Section titled “3. Two token types, deliberately different”3.1 Access token — JWT, 15 minutes
Section titled “3.1 Access token — JWT, 15 minutes”Signed HS256 with a kid header for key rotation. One binary issues and verifies its
own tokens; asymmetric keys would buy key distribution nobody needs.
| Claim | Meaning |
|---|---|
sub |
users.id |
org |
active organization — the sole source of tenant context |
mem |
organization_member.id, carried to save a lookup |
pv |
perm_version of the org at mint time (§4.3) |
sid |
session_family.id — what makes per-request revocation possible |
iat, exp, jti |
issued, +15 min, unique id |
3.2 Refresh token — opaque, not a JWT
Section titled “3.2 Refresh token — opaque, not a JWT”32 cryptographically random bytes, transmitted raw, stored only as a SHA-256 hash. A refresh token must be single-use and revocable, which is precisely what a stateless JWT is designed to prevent; making one stateless-by-format and then maintaining a revocation list beside it is the worst of both.
Cookie attributes: httpOnly, Secure, SameSite=Lax, Path scoped to the refresh
endpoint so it is not attached to ordinary API calls.
3.3 Passwords and second factor
Section titled “3.3 Passwords and second factor”Passwords hashed with Argon2id. Parameters live in one constant block in the auth package with a comment recording the machine they were tuned on, because copy-pasted-from-a-blog parameters are the usual failure.
TOTP is optional per user in Phase 1, enrolled by the user. totp_secret_enc is
encrypted at rest with a key held outside the database, so a database dump alone does
not yield working second factors. Recovery codes are single-use and stored hashed.
4. Rotation and reuse detection
Section titled “4. Rotation and reuse detection”On presentation of a refresh token:
| Condition | Outcome |
|---|---|
| Hash not found | Reject. No information about why. |
Found, used_at set, older than the grace window |
Replay. Revoke the entire family, write an audit row, force re-login. |
Found, used_at set, within the grace window |
Return the same pair already issued for it. |
| Found, unused, family revoked or expired | Reject. |
| Found, unused, valid | Mark used, issue a new refresh token in the same family and a new access token. |
4.1 The 10-second grace window
Section titled “4.1 The 10-second grace window”Strict single-use reuse detection has a well-known failure: two browser tabs refreshing simultaneously, or one mobile client retrying over a flaky connection, present the same token twice and the user is logged out for having bad wifi. The immediately previous token therefore remains acceptable for 10 seconds, returning the pair already minted for it rather than tripping revocation. Anything older, or any token other than the immediate predecessor, is a genuine replay and revokes the family.
4.2 Lifetimes
Section titled “4.2 Lifetimes”- Access token: 15 minutes.
- Idle: 14 days from
last_used_at. Each use extends it. - Absolute:
absolute_expires_atis set at login to +90 days and is never extended. A session cannot become a credential with no end date.
4.3 Concurrent sessions
Section titled “4.3 Concurrent sessions”A user may hold many session families at once — phone and laptop is a normal case for this product. Each family is listed to the user with device label, IP and last-used time, and the user, or an organization admin, may revoke any one of them or all of them. This is also the honest answer to a lost phone.
5. Switching organizations
Section titled “5. Switching organizations”POST /v1/auth/switch-org { "org_id": "..." }Validates membership against the authenticated session and mints an access token carrying
the new org claim. No re-authentication, no new session family.
X-Org-Id is removed from the middleware, from api/openapi.yaml, and from the
frontend. Its absence is the closure of the Phase 0 blocking item.
6. Per-request validation
Section titled “6. Per-request validation”The access token is stateless in format but not blindly trusted. Every authenticated
request performs one lookup, keyed by sid and org, answering three questions at once:
is this session family still live, is the member still active, and is pv current?
§4.3 already places a Redis permission-cache read on every request. This is that same round trip answering more questions — not a new cost. When Redis is unavailable, the lookup falls back to Postgres: slower, still correct, never fail-open.
A 15-minute window in which a dismissed employee retains approval authority is not acceptable in a system whose most valuable asset is the authority to approve payment.
6.1 Revocation semantics
Section titled “6.1 Revocation semantics”| Event | Mechanism | Takes effect |
|---|---|---|
| Member removed or deactivated | Bump org perm_version; member marked inactive |
Next request |
| Admin revokes a session | revoked_at on the family |
Next request |
| Password changed, or MFA enrolled | Revoke every family except the current one | Immediately |
| Role permissions edited | Bump perm_version (§4.3) |
Next request |
| Replay detected | Revoke the family (§4) | Immediately |
6.2 Login throttling
Section titled “6.2 Login throttling”Exponential backoff per email and per IP, Redis-backed, not hard lockout. A hard lockout hands anyone the ability to lock a named finance director out of the system by guessing their password wrong ten times.
7. Middleware chain
Section titled “7. Middleware chain”RequestID → AccessLog → Recover → Authenticate → Tenant → Authorize → InTx- Authenticate — verifies signature and expiry, performs the §6 liveness lookup, places the identity on the request context.
- Tenant — reads the signed
orgclaim. It reads nothing else. This is the body that replaces the Phase 0 placeholder. - Authorize — the permission check of §4.3 question 1. Record-level authorization remains the owning module’s job (§4.3 question 2).
- InTx — opens the transaction and sets
app.current_org(§4.1), unchanged from Phase 0.
8. Compliance
Section titled “8. Compliance”Per §11A of the parent spec.
- Audit (§11A.4, §11A.5). Every login, failed login, MFA enrolment and removal, replay
detection, session revocation and organization switch writes to
audit_logwith actor, IP and request ID. An auditor asking “who approved this, and were they still employed” gets an answer from the record. - Retention (§11A.2). Session rows are operational data, not statutory records.
They are pruned after absolute expiry plus a short tail. The seven-year clock applies to
ledger and document records, and must not be applied mechanically to everything with a
timestamp. The
audit_logentries describing those sessions are retained. - Residency (§11A.1). No external identity provider, so authentication data never leaves the Malaysian deployment. This was a deciding factor against SSO in Phase 1.
- Personal data. Email, password hash and TOTP secret are personal data and fall under the anonymization path of §11A.2.
9. Testing
Section titled “9. Testing”| Property | Test |
|---|---|
| Reuse detection | Replay a used token past the grace window ⇒ family revoked, both tokens dead |
| Grace window | Two concurrent refreshes ⇒ both succeed, one family, one new token pair |
| Absolute cap | Refresh at day 91 ⇒ rejected, using kernel/clock to travel |
| Idle expiry | Refresh after 15 days idle ⇒ rejected |
| Tenant isolation | A token for org A cannot read org B, at both the middleware and RLS layers |
| Revocation latency | Deactivate a member ⇒ the next request fails, not the one after 15 minutes |
| Redis outage | Redis down ⇒ requests still authenticate correctly via Postgres, and a revoked session still fails |
| Layering | No package under internal/modules/ references an RLS-exempt table |
| Throttling | Repeated failures back off; a third party cannot lock a known account |
10. Decisions, with what each costs if wrong
Section titled “10. Decisions, with what each costs if wrong”- Org is a signed claim;
X-Org-Iddeleted. Cost: an org switch is a round trip. - Session family is the login, not the org. Cost: three RLS-exempt tables, contained by the module-reference rule of §2.2.
- Email + password, TOTP optional. Cost: MFA is not mandatory for approval roles in Phase 1; making it mandatory later is an enrolment campaign, not a schema change.
- Refresh token opaque, not a JWT. Cost: every refresh touches the database.
- 14-day idle, 90-day absolute. Cost: a stolen unlocked laptop is useful for up to 14 days.
- 10-second grace window. Cost: a genuine replay inside 10 seconds of the legitimate use is not detected — the trade taken to avoid logging users out for poor connectivity.
- Per-request liveness lookup. Cost: Redis on the hot path, with a Postgres fallback that is slower under outage.
- Backoff, not lockout. Cost: a determined attacker gets more attempts than a lockout would allow, at a rate that makes guessing useless.