Hardening
Why each control sits where it does and, where one is absent, why. Scope and reporting live in
SECURITY.md. The threat that dominates at small scale is a lost, stolen or shared device, and the worst
case is a privacy breach, not fraud.
Abuse resistance
Three unauthenticated risks: email-bomb against the code, recovery and verification flows; credential brute-force, since Kratos ships no account lockout by design; and password-hash exhaustion, where the argon2 memory parameter that makes hashing strong becomes the denial-of-service lever.
Two gaps are open: nothing caps concurrent argon2 operations, so the memory-exhaustion lever is unbounded regardless of source-IP diversity; and the email-code lifespan outlives its login flow, so a code can arrive still valid against a flow that has expired.
Browser hardening on the auth UI
portal/src/middleware.ts sets one header baseline on every response, asserted by
e2e/playwright/tests/m-security-headers.spec.ts. Two choices are non-obvious.
There is no script-src and no default-src in the CSP. A strict
script-src breaks the Kratos WebAuthn ceremony, which injects inline script
nodes, and breaks the portal's own inline theme setup. Adding one needs live
validation of every auth flow and is deferred, so the CSP here is a framing, base-URI and
form-target control, and not XSS insurance.
form-action cannot be 'self' alone: it governs a submission's whole redirect
chain, and an OIDC login's redirect lands on the requesting app's own domain.
Referrer-Policy is same-origin, not no-referrer. Under no-referrer,
Chromium sends Origin: null on a same-origin form POST, which Astro's
checkOrigin rejects, making every admin mutation unreachable. same-origin
still sends nothing cross-origin, which matters because flow and
consent-challenge identifiers live in URLs. For the same reason no asset loads
from an external CDN.
Admin-API isolation
Ory's Kratos and Hydra admin APIs are unauthenticated by design. The network is the boundary, so how those ports are reachable is the most important deployment decision.
A second network alone does not segment it. A socket bound to
0.0.0.0 answers on every interface the container has. Attaching Kratos to an
additional internal-only network does not hide its admin port from the first
one. Segmentation needs that second network and an admin listener bound to
its address. A deployment doing only the first is not segmented. The admin
bind address is therefore a required variable, and the Ory config render fails
when any token is unset, though nothing checks which address was supplied.
The portal reaches the admin API with no mTLS and no client certificate, so network isolation is the load-bearing control on that hop. An edge proxy attached to both networks segments the admin APIs against every container outside the auth stack, and not against a compromise of the edge proxy itself.
No password wall can sit in front of the whole thing. An identity
provider serves unauthenticated callers: browsers at login and relying-party
servers doing token exchange, discovery and JWKS fetches. A shared wall breaks
passkey-first login and every OAuth2 client. What is gated is /admin, where requireAdminSession in
portal/src/lib/admin.ts enforces an active session, aal2 and the admin
marker on every request; e2e/playwright/tests/d-admin-aal2-stepup.spec.ts and
f-aal2-bypass.spec.ts pin it.
Secrets and the shipped baseline
Ory's secrets are file-mounted; the portal's come from the process
environment, and environment secrets are readable through /proc to anything
in the user namespace. Closing that means a tmpfs-backed mount and a reader that
takes a path, a known gap. No committed config holds
plaintext. The config render runs as the Kratos and Hydra uid because the capability drop is load-bearing:
without CAP_DAC_OVERRIDE, a 0600 config owned by another uid is unreadable and every Ory service exits.
Also open: the example compose sets no memory bound (set one in the deploy plane, sized to the host; Kratos needs about 128 MB per concurrent argon2 hash), so the hash-exhaustion lever above is unbounded; and nothing verifies a signature before use, which belongs to whatever runs the image, not to this repository.
Sessions and eviction
The admin gate checks assurance and freshness. Session lifetime is
long for passkey-first daily use, so /admin additionally gates on when aal2 was established: isSessionFresh
(portal/src/lib/admin.ts) redirects a stale session to the same step-up flow
as a missing aal2, before the admin-marker fetch. Window: 1h.
Eviction must remove credentials, not just sessions. Revoking sessions does not delete enrolled
credentials, so someone with a brief session can enrol their own passkey and it survives both the revoke
and a password reset. Kratos's admin API has no per-passkey removal, so self-remove at /auth/settings is the
only passkey eviction path; the ladder is in docs/runbooks/lost-device.md.
Revoking is one fan-out, and it shares an invariant with the login path's skip=true handling: Hydra's memory is never trusted on its own.
No notification on email-code login. A message to a secondary address would collapse the detection window from "whenever the victim notices" to minutes.
Device-layer controls (screen lock, remote wipe) stay the operator's, and are the real mitigation for a lost device. So do host hardening beneath the network boundary, volumetric denial of service, and backup restore correctness across Hydra's encryption at rest.