6.2 KiB
Web Authentication and CSRF Security Guidelines
This document describes the security baseline currently implemented for Harmony browser-facing dashboards that use Axum, HTMX, cookie sessions, and Zitadel/OIDC.
It is intentionally limited to controls that are present in the code today. Items such as role-based authorization and server-side session storage are not documented here until implemented.
Current Baseline
Harmony dashboards currently use:
- Zitadel/OIDC Authorization Code + PKCE for login.
- OIDC nonce binding on login callback.
- JWT session cookie verification on protected routes.
- CSRF protection on mutating protected routes.
- Security headers, including Content Security Policy.
- No application-level CORS policy for the dashboard.
OIDC Login Flow
On /login, the application creates a short-lived login attempt containing:
statepkce_code_verifiernonce
The login attempt is stored in an encrypted private cookie with a short max age.
The authorize URL sent to Zitadel includes:
client_idredirect_uriresponse_type=code- requested
scope - PKCE
code_challenge code_challenge_method=S256statenonce
On /auth/callback, the application:
- Reads the encrypted login-attempt cookie.
- Validates the returned
state. - Exchanges the authorization code using the stored PKCE verifier.
- Verifies the returned ID token JWT using Zitadel JWKS.
- Validates issuer and configured trusted audiences.
- Validates that the ID token
noncematches the stored login-attempt nonce. - Deletes the login-attempt cookie.
- Stores the verified ID token in the application session cookie.
state, PKCE, and nonce serve different purposes:
statebinds the callback redirect to the browser login attempt.- PKCE binds the authorization code exchange to the client that started login.
noncebinds the returned ID token to the browser login attempt.
Session Cookie
The current dashboard session cookie stores the raw Zitadel ID token.
The session cookie is configured with:
HttpOnlySameSite=LaxPath=/SecurewhenBASE_URLstarts withhttps://Max-Agederived from the ID tokenexpclaim when available
On every protected request, the application verifies the session cookie JWT against cached Zitadel JWKS before allowing access.
Local logout removes the application session cookie and redirects to Zitadel end-session with the ID token as id_token_hint.
Clearing only the application cookies does not necessarily log the user out of Zitadel. If the Zitadel SSO session cookie still exists on the Zitadel origin, the user may be immediately re-authenticated when redirected through /login.
CSRF Protection
CSRF protection is applied to protected mutating routes.
Mutating methods are:
POSTPUTPATCHDELETE
For these methods, the application requires:
-
A custom request header:
x-csrf-token: 1 -
A same-origin
Originheader, or ifOriginis absent, a same-originRefererheader.
The expected origin is derived from configured BASE_URL.
Requests are rejected when:
- the custom CSRF header is missing;
Originis present and does not matchBASE_URLorigin;Originis absent andRefereris missing or does not matchBASE_URLorigin.
The CSRF header value is static. The protection relies on browser behavior: cross-origin HTML forms cannot set custom headers, and cross-origin JavaScript cannot send credentialed custom-header requests unless the server opts into CORS.
HTMX Integration
The dashboard serves a static JavaScript file at /static/app.js.
That file adds the CSRF header to HTMX requests:
document.body.addEventListener('htmx:configRequest', (event) => {
event.detail.headers['x-csrf-token'] = '1';
});
This keeps CSRF wiring centralized instead of adding hidden fields or per-form tokens to every HTMX action.
Security Headers
The dashboard adds security headers globally.
Current production-oriented Content Security Policy:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
In live-reload development mode, script-src also allows inline scripts for the dev reload helper.
The current CSP means:
- scripts load from the application origin;
- styles load from the application origin, with inline styles currently allowed;
- images load from the application origin or
data:URLs; - HTMX, fetch, and SSE connections go to the application origin;
- the dashboard cannot be framed;
- injected
<base>tags cannot redirect relative links away from the app origin; - forms can submit only to the app origin;
- plugin/object content is disabled.
The application also sets:
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()
When BASE_URL is HTTPS, it also sets:
Strict-Transport-Security: max-age=31536000; includeSubDomains
CORS Policy
The dashboard does not currently configure a permissive application CORS policy.
This is important because the CSRF design depends on untrusted origins being unable to send credentialed requests with the custom x-csrf-token header.
Do not add credentialed CORS for arbitrary or reflected origins without revisiting the CSRF design.
Current Implementation Checklist
The current Harmony dashboard baseline is:
- Login uses Authorization Code + PKCE.
- Login attempt stores
state, PKCE verifier, andnonce. - Login-attempt cookie has a short max age.
- Callback validates
state. - Callback validates ID token nonce.
- JWT validation checks signature, issuer, expiration, and configured trusted audience.
- Protected routes require a verified session cookie.
- Mutating protected routes require CSRF header and same-origin
Origin/Referer. - Session cookies are
HttpOnly,SameSite=Lax, andSecurewhenBASE_URLis HTTPS. - Static HTMX helper adds the CSRF header.
- Security headers are configured.
- No permissive dashboard CORS policy is configured.