# ADR 0002 — Password hashing, one-time tokens, and no-enumeration

- Status: Accepted
- Date: 2026-08-19

## Context

Phase 4 introduces user accounts, registration, and email activation. Three
security-sensitive decisions had to be made before Phase 5 layers JWT auth on top.

## Decisions

### 1. Argon2id behind a DelegatingPasswordEncoder

Passwords are hashed with **Argon2id** using the OWASP-minimum parameters:
19 MiB memory, 2 iterations, parallelism 1, 16-byte salt, 32-byte hash. The
encoder is wrapped in Spring Security's `DelegatingPasswordEncoder` with the
`{argon2}` prefix, so hashes are self-describing and the algorithm can be
migrated later without a data rewrite. The `users.password` column stores this
hash only — a column comment records that, and no plaintext ever touches the DB
or logs. We pulled in `spring-security-crypto` (+ BouncyCastle) rather than
`spring-boot-starter-security`, to avoid activating web security auto-config
before Phase 5.

### 2. One-time tokens: random, SHA-256-hashed, single-use

Activation/reset tokens are 32 bytes from `SecureRandom`, Base64URL-encoded.
Only the **SHA-256 hex hash** is persisted (`one_time_tokens.token_hash`, unique).
Verification hashes the presented token and looks it up by hash — so the raw
token is never stored and never compared byte-by-byte, sidestepping timing
side-channels. Tokens carry an expiry and a `consumed_at`; consumption is
single-use and a consumed/expired token is indistinguishable in the response
(`TOKEN_INVALID`). One table serves both `ACCOUNT_ACTIVATION` and
`PASSWORD_RESET` via a `type` discriminator.

### 3. Registration does not leak account existence

`POST /auth/register` returns an identical `201` with a fixed acknowledgement
message whether or not the email already exists. When it exists, nothing is
created and no email is sent; when it is new, an inactive user is created and an
activation email dispatched. A caller cannot use the response to enumerate
registered addresses. Email is normalized (trim + lower-case) and uniqueness is
enforced case-insensitively via `ux_users_email_lower`.

### 4. Password policy as a Bean Validation constraint

A custom `@StrongPassword` constraint enforces min-12 characters and a small
common-password deny list, applied to the registration DTO. It lives with the
DTO validation so failures surface as the standard `VALIDATION_FAILED` problem,
not a bespoke flow. The deny list is illustrative; production would back it with
a breached-password service.

## Consequences

- `auth.service` is now under the coverage gate (≥90% line): achieved 96.9%
  line / 100% branch; PIT 83% across service packages.
- Deferred to later phases (unchanged intent): activation **resend** + rate
  limiting on `/auth/**` (Phase 6); login, lockout, and refresh tokens (Phase 5).
- Mail is delivered via `MailSender`/`SimpleMailMessage` over SMTP — MailHog in
  dev, GreenMail in tests. HTML templating is out of scope.
