# 0005. JWT access token + rotating refresh token (hashed in DB)

- **Status:** Accepted
- **Date:** 2026-05-26
- **Deciders:** Loma Group Platform
- **Related:** [`SECURITY.md`](../../SECURITY.md), [`enterprise-modules/auth/README.md`](../../enterprise-modules/auth/README.md), [`.claude/skills/auth-flow/SKILL.md`](../../.claude/skills/auth-flow/SKILL.md)

## Context

We need stateless API auth across multiple Node hosts + the .NET API. Sessions in Redis would couple every host to a single shared store; full JWT-only would be unsafe (no revocation, long-lived tokens leak).

## Decision

- **.NET API issues both tokens.** No Node host issues tokens.
- **Access token:** JWT HS256 signed with `JWT_ACCESS_SECRET` (≥ 32 chars). 15-minute TTL. Claims: `sub` (userId), `email`, `tenant_id`, `role[]` (one per role), `perm[]` (one per permission), `jti`.
- **Refresh token:** opaque random base64url string. 30-day TTL. Stored in DB as SHA-256 hash, never plaintext. Rotated on every use; old token marked `revoked_at + replaced_by_token_hash`.
- **Node hosts verify** via `@loma-portal/em-auth` (jose) — no shared key beyond the access secret.
- **Password hashing:** Argon2id (`Argon2PasswordHasher`). Memory 64 MiB, iterations 3, parallelism 2 (tune per host).
- **Seed uses scrypt** (no native deps for Node); the API rehashes to Argon2id on first successful login.

## Alternatives considered

| Option                           | Why rejected                                                                                                                        |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| Session cookies                  | Requires sticky sessions or a shared session store across services.                                                                 |
| Long-lived JWT only (no refresh) | No revocation; a leaked token works until expiry.                                                                                   |
| Token in cookie (httpOnly)       | We do this for `apps/web-next` (Server Action sets `lp_access`). For Angular we use Bearer headers because the API is cross-origin. |
| OAuth2 with an external IdP      | Overkill for an internal portal today. Path forward: add an IdP-federated provider later without changing the JWT layer.            |

## Consequences

### Positive

- Revocation works: revoke the refresh token + access expires within 15 min.
- Multiple hosts can verify independently.
- The .NET API is the only writer of identity state.

### Negative

- 15-minute TTL means more refresh calls — acceptable; refresh path is fast.
- Two secrets to manage (access + refresh).

### Neutral

- We deliberately use HS256 (symmetric) — RS256 would let services verify with a public key but adds key-management cost we don't need yet.

## Compliance

- Audit on every `auth.login` / `auth.refresh` / `auth.logout` / `auth.login_failed`.
- Refresh tokens hashed at rest (Confidential → never logged).
- Password complexity + Argon2id meet PDPA + GDPR "appropriate technical measures".

## References

- jose: https://github.com/panva/jose
- Konscious.Argon2: https://github.com/kmaragon/Konscious.Security.Cryptography
