Authentication
Paddock has no login of its own. It is designed to run behind a reverse
proxy that has already authenticated the user against an identity provider
(OIDC/SAML/etc.), and to turn that upstream identity into a req.user that the
rest of the app can read.
The auth layer is provider-agnostic — driven entirely by PADDOCK_AUTH_*
environment variables — so it is not tied to any single proxy or IdP. It works
with Authentik, oauth2-proxy, Authelia, Cloudflare Access, Keycloak, and others.
All of it is optional. The default (PADDOCK_AUTH_MODE=none) is fully open.
The three modes
Section titled “The three modes”| Mode | What it trusts | Spoof-proof on its own? | Use when |
|---|---|---|---|
none (default) | nothing | n/a (open) | Local dev, or trust is entirely handled elsewhere |
trusted-header | a header the proxy sets | No — relies on the proxy + network | The proxy is the only path to Paddock |
jwt | a signed JWT verified against a JWKS | Yes | Zero-trust; safe even if a request reaches Paddock directly |
Security note
Section titled “Security note”none and trusted-header provide no cryptographic guarantee. In
trusted-header mode, anything that can reach Paddock can forge the identity
header. That is only acceptable when the network guarantees the proxy is the
sole ingress (e.g. Paddock binds a private interface / Docker network and the
proxy is the one hop in front of it).
jwt mode is the only spoof-proof option. Paddock verifies the token’s
signature itself against the IdP’s JWKS, so a forged or replayed-without-key
token is rejected even if it arrives directly. Paddock holds no key material
— only the JWKS URL. Key rotation is handled automatically (jose’s
createRemoteJWKSet fetches + caches the JWKS).
What the auth hook does not gate
Section titled “What the auth hook does not gate”Either way, Paddock’s health endpoint is always exempt so the proxy and
monitoring can probe a locked-down server: /api/health, which answers
200 {"ok":true} as application/json. It is the only exempt health path — point
every liveness/readiness probe at it (the shipped Dockerfile HEALTHCHECK and the
Kubernetes probes both do).
It is not, however, the only exempt path. If you’re auditing what an unauthenticated client can reach, this is the whole list — three groups, and nothing else:
| Exempt | What it is | Why |
|---|---|---|
/api/health | The health route above. | Probes must work on a locked-down instance. |
/assets/, /icons/, /fonts/ (prefixes) and /sw.js, /manifest.webmanifest, /favicon.ico | The compiled front-end bundle — content-hashed JS/CSS/fonts, the service worker, the PWA manifest. | They carry no secrets and no per-user data, and gating them turns a transient auth lapse (an SSO session refresh, the proxy briefly not injecting the header) into a 401 on a chunk fetch and a broken app. |
/mcp and /.well-known/oauth-protected-resource… (prefixes) | The Management API and its OAuth discovery document. | They authenticate themselves, independently of PADDOCK_AUTH_MODE. |
Two things worth being precise about. The static-asset exemption covers the compiled
bundle only — the app shell (index.html and the client routes) and every data route
(/api/…, /ws) stay authenticated, so this exposes nothing an authenticated user’s
browser doesn’t already download. And /mcp being exempt from this hook does not make
it open: it runs its own authenticator and fails closed — 404 when unconfigured, 401
otherwise — even at PADDOCK_AUTH_MODE=none. That is also why it must be exempt: jwt
mode reads Authorization, which collides head-on with an MCP client’s own bearer token.
Matching strips the query string and one trailing slash, so /api/health?probe=1 is
exempt too. Everything else — including any path no route serves — is gated.
Environment variables
Section titled “Environment variables”| Variable | Mode | Default | Purpose |
|---|---|---|---|
PADDOCK_AUTH_MODE | all | none | none | trusted-header | jwt |
PADDOCK_AUTH_USER_HEADER | trusted-header | X-Forwarded-User | Header carrying the username (required in this mode) |
PADDOCK_AUTH_EMAIL_HEADER | trusted-header | — | Optional header carrying the email |
PADDOCK_AUTH_GROUPS_HEADER | trusted-header (also jwt override) | — | Optional header carrying groups (comma/space-split) |
PADDOCK_AUTH_JWT_HEADER | jwt | Authorization | Header carrying the JWT. If Authorization, a leading Bearer is stripped |
PADDOCK_AUTH_JWKS_URL | jwt | — | Required in jwt mode. The IdP’s JWKS endpoint |
PADDOCK_AUTH_JWT_ISSUER | jwt | — | Optional; validate the iss claim |
PADDOCK_AUTH_JWT_AUDIENCE | jwt | — | Optional; validate the aud claim |
PADDOCK_AUTH_USERNAME_CLAIM | jwt | — | Claim to read the username from. Default tries preferred_username → email → sub |
PADDOCK_AUTH_GROUPS_CLAIM | jwt | groups | Claim to read group membership from |
In jwt mode, Paddock validates iss/aud only when you set them, and always
validates the signature and expiry (exp). Supported signature algorithms are
the asymmetric ones JWKS publishes (RS256, ES256, etc.).
If PADDOCK_AUTH_MODE=jwt is set without PADDOCK_AUTH_JWKS_URL, Paddock
refuses to start (fails closed, loudly) rather than booting an
auth-misconfigured server.
What req.user looks like
Section titled “What req.user looks like”After the auth hook runs, every request carries:
interface AuthUser { username: string; // from header/claim email?: string; // when the provider supplies it groups?: string[]; // when supplied anonymous?: boolean; // true only in `none` mode}In none mode this is the synthetic { username: "anonymous", anonymous: true }.
Provider examples
Section titled “Provider examples”Authentik (forward-auth, jwt mode) — our deployment
Section titled “Authentik (forward-auth, jwt mode) — our deployment”Authentik’s forward-auth outpost injects a signed JWT in the X-authentik-jwt
header. Each Authentik application exposes its own JWKS at
https://<authentik-host>/application/o/<app-slug>/jwks/.
PADDOCK_AUTH_MODE=jwtPADDOCK_AUTH_JWT_HEADER=X-authentik-jwtPADDOCK_AUTH_JWKS_URL=https://sso.example.com/application/o/<app-slug>/jwks/# optional hardening:# PADDOCK_AUTH_JWT_ISSUER=https://sso.example.com/application/o/<app-slug>/# PADDOCK_AUTH_JWT_AUDIENCE=<client-id>Username maps from preferred_username by default; groups from groups.
The proxy (Caddy + Authentik outpost) handles
/outpost.goauthentik.io/*itself — those paths never reach Paddock, so no exemption is needed for them.
oauth2-proxy (trusted-header)
Section titled “oauth2-proxy (trusted-header)”oauth2-proxy sets X-Forwarded-User / X-Forwarded-Email (enable
--set-xauthrequest / --pass-user-headers). Make sure Paddock is only
reachable through oauth2-proxy.
PADDOCK_AUTH_MODE=trusted-headerPADDOCK_AUTH_USER_HEADER=X-Forwarded-UserPADDOCK_AUTH_EMAIL_HEADER=X-Forwarded-EmailPADDOCK_AUTH_GROUPS_HEADER=X-Forwarded-Groupsoauth2-proxy can alternatively pass a JWT (--pass-access-token /
Authorization: Bearer), in which case use jwt mode with the IdP’s JWKS URL.
Cloudflare Access (jwt mode)
Section titled “Cloudflare Access (jwt mode)”Cloudflare Access injects a signed JWT in the Cf-Access-Jwt-Assertion header
and publishes a JWKS per team.
PADDOCK_AUTH_MODE=jwtPADDOCK_AUTH_JWT_HEADER=Cf-Access-Jwt-AssertionPADDOCK_AUTH_JWKS_URL=https://<team>.cloudflareaccess.com/cdn-cgi/access/certsPADDOCK_AUTH_JWT_AUDIENCE=<application-aud-tag>PADDOCK_AUTH_USERNAME_CLAIM=emailAuthelia / Keycloak
Section titled “Authelia / Keycloak”Authelia (Remote-User / Remote-Email / Remote-Groups) works in
trusted-header mode; Keycloak issuing a Bearer JWT works in jwt mode with
the realm JWKS (.../realms/<realm>/protocol/openid-connect/certs).
Implementation notes
Section titled “Implementation notes”- Wiring lives in
packages/server/src/auth.ts; config inpackages/server/src/config.ts(cfg.auth). It is registered inpackages/server/src/app.tsas anonRequesthook before the routes and the WebSocket handler, so it guards both REST and/ws. - The verification library is
jose(createRemoteJWKSet+jwtVerify) — dependency-light and standards-based. req.useris exposed via a Fastify request decorator (TypeScript-augmented), so any route/handler can read it without extra plumbing.