Skip to content

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.


ModeWhat it trustsSpoof-proof on its own?Use when
none (default)nothingn/a (open)Local dev, or trust is entirely handled elsewhere
trusted-headera header the proxy setsNo — relies on the proxy + networkThe proxy is the only path to Paddock
jwta signed JWT verified against a JWKSYesZero-trust; safe even if a request reaches Paddock directly

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).

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:

ExemptWhat it isWhy
/api/healthThe health route above.Probes must work on a locked-down instance.
/assets/, /icons/, /fonts/ (prefixes) and /sw.js, /manifest.webmanifest, /favicon.icoThe 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.


VariableModeDefaultPurpose
PADDOCK_AUTH_MODEallnonenone | trusted-header | jwt
PADDOCK_AUTH_USER_HEADERtrusted-headerX-Forwarded-UserHeader carrying the username (required in this mode)
PADDOCK_AUTH_EMAIL_HEADERtrusted-headerOptional header carrying the email
PADDOCK_AUTH_GROUPS_HEADERtrusted-header (also jwt override)Optional header carrying groups (comma/space-split)
PADDOCK_AUTH_JWT_HEADERjwtAuthorizationHeader carrying the JWT. If Authorization, a leading Bearer is stripped
PADDOCK_AUTH_JWKS_URLjwtRequired in jwt mode. The IdP’s JWKS endpoint
PADDOCK_AUTH_JWT_ISSUERjwtOptional; validate the iss claim
PADDOCK_AUTH_JWT_AUDIENCEjwtOptional; validate the aud claim
PADDOCK_AUTH_USERNAME_CLAIMjwtClaim to read the username from. Default tries preferred_usernameemailsub
PADDOCK_AUTH_GROUPS_CLAIMjwtgroupsClaim 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.


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 }.


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/.

Terminal window
PADDOCK_AUTH_MODE=jwt
PADDOCK_AUTH_JWT_HEADER=X-authentik-jwt
PADDOCK_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 sets X-Forwarded-User / X-Forwarded-Email (enable --set-xauthrequest / --pass-user-headers). Make sure Paddock is only reachable through oauth2-proxy.

Terminal window
PADDOCK_AUTH_MODE=trusted-header
PADDOCK_AUTH_USER_HEADER=X-Forwarded-User
PADDOCK_AUTH_EMAIL_HEADER=X-Forwarded-Email
PADDOCK_AUTH_GROUPS_HEADER=X-Forwarded-Groups

oauth2-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 injects a signed JWT in the Cf-Access-Jwt-Assertion header and publishes a JWKS per team.

Terminal window
PADDOCK_AUTH_MODE=jwt
PADDOCK_AUTH_JWT_HEADER=Cf-Access-Jwt-Assertion
PADDOCK_AUTH_JWKS_URL=https://<team>.cloudflareaccess.com/cdn-cgi/access/certs
PADDOCK_AUTH_JWT_AUDIENCE=<application-aud-tag>
PADDOCK_AUTH_USERNAME_CLAIM=email

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).


  • Wiring lives in packages/server/src/auth.ts; config in packages/server/src/config.ts (cfg.auth). It is registered in packages/server/src/app.ts as an onRequest hook 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.user is exposed via a Fastify request decorator (TypeScript-augmented), so any route/handler can read it without extra plumbing.