Skip to content

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.


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-Id header, 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.


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.

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”

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

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.

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.


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.

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.

  • Access token: 15 minutes.
  • Idle: 14 days from last_used_at. Each use extends it.
  • Absolute: absolute_expires_at is set at login to +90 days and is never extended. A session cannot become a credential with no end date.

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.


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.


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.

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

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.


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 org claim. 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.

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_log with 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_log entries 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.

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”
  1. Org is a signed claim; X-Org-Id deleted. Cost: an org switch is a round trip.
  2. Session family is the login, not the org. Cost: three RLS-exempt tables, contained by the module-reference rule of §2.2.
  3. 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.
  4. Refresh token opaque, not a JWT. Cost: every refresh touches the database.
  5. 14-day idle, 90-day absolute. Cost: a stolen unlocked laptop is useful for up to 14 days.
  6. 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.
  7. Per-request liveness lookup. Cost: Redis on the hot path, with a Postgres fallback that is slower under outage.
  8. Backoff, not lockout. Cost: a determined attacker gets more attempts than a lockout would allow, at a rate that makes guessing useless.