Three layers of login hardening pulled together — addresses the
"don't let middleman / robot easily log in by mimicking headers"
follow-up.
1. AES-256-GCM session cookie (apps/web/src/lib/auth-cookie.ts)
The old format was base64-encoded JSON + HMAC-SHA256 signature, so
anyone with the cookie could read userId/role straight off the
bytes. Switched to AES-GCM authenticated encryption: the payload
is encrypted with a 256-bit key derived from AUTH_SECRET via
SHA-256, a fresh 12-byte nonce is drawn per encryption (never
reused — locked in by test), and tampering with either the IV or
ciphertext fails the GCM auth tag → decrypt throws → null.
Cookie format: <base64url(iv)>.<base64url(ciphertext+tag)>
Existing cookies become invalid on deploy because the IV portion
doesn't decode to 12 bytes — middleware bounces them to /login.
No env bump needed; users just sign in once with the new secret.
2. Three-layer rate limit on loginAction
Old: per-IP only. An attacker with a residential-proxy pool or
spoofed X-Forwarded-For could hop IPs and brute one account.
New: Promise.all of three checkRateLimit calls
- per-IP login:<ip> 10 / 5 min
- per-username login-user:<lower> 5 / 15 min
- global login-global 100 / min (backstop)
First-hit wins; logger captures which limit tripped (ip / username
/ global) without telling the attacker which one.
3. Action-level Origin/Host check
serverActions.allowedOrigins already does this at the framework
layer; running it inside loginAction lets us log the mismatch and
reject before bcrypt + DB. Missing Origin treated as same-origin
(RFC: same-origin POSTs may omit it). Malformed Origin → reject.
Tests:
- auth-cookie.test.ts updated to AES-GCM (15 tests, +4 vs HMAC):
fresh IV per encryption, ciphertext doesn't leak userId/role,
IV-swap rejected, ciphertext-tamper rejected, wrong-length IV
rejected, malformed b64 doesn't throw.
- auth.test.ts adds 7 new cases: three-layer key shape, per-username
limit alone trips, global limit alone trips, cross-origin rejected,
same-origin accepted, missing-Origin treated as same-origin,
malformed-Origin rejected.
Web suite 453 → 463 tests, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Setting Secure on http://localhost cookies works in Chrome (localhost
exception) but Firefox/Safari silently drop them, so dev users hit
'redirect to /login on every click' after a 'successful' login. Switch
to secure: NODE_ENV === 'production'. Public deploy still gets
Secure-only.
Also swap the login footer copy from a CLI hint to 'Forget Password?
Contact IT' — operator-friendly, doesn't leak the bootstrap
mechanism on the public sign-in screen.
Test updated to assert secure=true under prod NODE_ENV and a new test
locks in secure=false in dev.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Next.js 16 typed-routes (experimental.typedRoutes in next.config.ts)
narrows redirect()'s parameter to RouteImpl<T>, which a runtime
string from the form can't satisfy. Cast to any with a comment for
the two redirect call sites in auth.ts.
The auth.test.ts redirectMock used `() =>` zero-arg signature, which
typescript rejected once the action started passing the path through.
Change to `(_path: string) =>` so the signature matches and the test
still passes (vitest's esbuild-transpiled run was fine; tsc caught it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>