Authentication
Cetacean authenticates requests with one of five providers, selected by auth.mode: none, oidc,
tailscale, cert or headers. One provider is active at a time. The default is none, which allows anonymous
access.
Authentication establishes identity only. To control which resources an identity may view or change, see Authorization.
Quick start
Every setting on this page is available as an environment variable, a config file key, and most also as a
command-line flag. The examples show the environment of the Cetacean service in your compose.yaml; see
Configuration for the flags and the precedence rules. Secret settings accept a _FILE variant
that reads the value from a file at startup, which is how a Docker secret reaches the process.
# The default. Nothing to configure.
environment:
CETACEAN_AUTH_MODE: none
environment:
CETACEAN_AUTH_MODE: oidc
CETACEAN_AUTH_OIDC_ISSUER: https://idp.example.com
CETACEAN_AUTH_OIDC_CLIENT_ID: cetacean
CETACEAN_AUTH_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_secret
CETACEAN_AUTH_OIDC_REDIRECT_URL: https://cetacean.example.com/auth/callback
environment:
CETACEAN_AUTH_MODE: tailscale
volumes:
- /run/tailscale/tailscaled.sock:/run/tailscale/tailscaled.sock:ro
environment:
CETACEAN_AUTH_MODE: cert
CETACEAN_AUTH_CERT_CA: /run/secrets/ca.pem
CETACEAN_TLS_CERT: /run/secrets/server.pem
CETACEAN_TLS_KEY: /run/secrets/server-key.pem
environment:
CETACEAN_AUTH_MODE: headers
CETACEAN_AUTH_HEADERS_SUBJECT: X-Remote-User
# Without it no groups are read, and grants with a group: audience match no one.
CETACEAN_AUTH_HEADERS_GROUPS: X-Remote-Groups
CETACEAN_TRUSTED_PROXIES: "10.0.0.0/8"
Identity
Every provider fills the same identity record. Read it with GET /auth/whoami. subject is the unique
identifier; groups feeds authorization audience matching.
| Field | none | oidc | tailscale | cert | headers |
|---|---|---|---|---|---|
subject | anonymous | sub claim | numeric user ID | SPIFFE URI SAN, else CN, else email | subject header |
displayName | Anonymous | name, else preferred_username | Tailscale display name | CN, else the SPIFFE ID path | name header, else subject |
email | — | email claim | Tailscale login name | first email SAN | email header |
groups | — | groups claim | app capability | Organizational Unit (OU) values | groups header, comma-separated |
Exempt paths
These paths skip authentication in every mode:
/-/*, except/-/resync- Health, readiness, metrics, SBOM, and licenses. Resync sweeps the Docker API, so it authenticates
/api,/api/*- API documentation and the JSON-LD context
/assets/*- Dashboard static assets
/auth,/auth/*- Login, callback, logout and
whoami /mcp- The MCP server guards itself, as the following paragraph describes
/.well-known/*- OAuth discovery documents, unauthenticated by spec
/oauth/token,/oauth/revoke,/oauth/register,/oauth/jwks- The grants carry their own proof in the body, and the key set is public
When mcp.enabled is set, what guards /mcp depends on the configuration: with oauth.enabled the MCP server verifies
a bearer token it issued; with the authorization server off, the active mode must be listed in
mcp.auth_bypass—there’s no bearer check then, and the upstream provider authenticates
every request instead. Cetacean refuses to start with neither.
/oauth/authorize isn’t exempt: a user must authenticate before granting a client access. That’s also why
the authorization server can’t run under the none mode—there would be no one to ask.
Exempt means anyone who can reach the port can read these. /-/sbom.cdx, with or without .json, lists
every component in the build with its exact version, which tells a scanner which advisories apply without a
credential, and /-/docker-latest-version makes Cetacean call GitHub. If the port is reachable from a network you don’t trust,
block both at your reverse proxy—nothing in the dashboard needs the SBOM, and without the version check a node’s
page just shows no update hint. server.version_check also turns the version check off at the source. /-/metrics reports request and cache counters; restrict it to your Prometheus
the same way.
Refused requests
A 401 must name a way to authenticate, in a WWW-Authenticate challenge
(RFC 9110 §15.5.2). Only OIDC has one to give: the
other modes read a credential HTTP can’t ask for—a certificate in the TLS layer, a header your proxy set,
the peer’s place on your tailnet—so a refusal there is a 403, and the error code says which.
| Mode | Refusal | Challenge |
|---|---|---|
oidc | 401 AUT001 | Bearer |
cert | 403 AUT005 | — |
tailscale, headers | 403 AUT006 | — |
For tailscale and headers the reason is logged, not returned: whether the peer was outside the tailnet,
had no node or user info, the whois lookup failed, the request bypassed the trusted proxy, or its proxy
secret or subject header was wrong describes your deployment to a caller that hasn’t authenticated. A
cert refusal names its reason, such as client certificate required.
None
Anonymous access. Every request receives a static identity with subject: anonymous.
This mode also bypasses authorization: a configured authorization policy has no effect while auth.mode is
none. Use it when Cetacean sits behind a VPN, a firewall, or a proxy that authenticates for you.
OIDC
OpenID Connect with the authorization code flow for browsers
and ID token validation for scripts. Requires auth.oidc.issuer,
auth.oidc.client_id, auth.oidc.client_secret and
auth.oidc.redirect_url; the redirect URL must use HTTPS unless it points at a loopback
address. See OIDC configuration for all parameters.
With server.public_url set, auth.oidc.redirect_url is derived as
{public_url}{base_path}/auth/callback and only needs setting if your callback lives elsewhere.
A request is matched against each credential in turn, and only one that accepts HTML is ever redirected:
Browser flow
Opening the dashboard unauthenticated sends you to your IdP to sign in, then back to the page you asked for.
GET /auth/login starts the same flow explicitly and honors a relative ?redirect= path.
Machine flow
Send an ID token as a bearer token. It’s verified against the IdP’s JWKS endpoint on every request.
GET /services HTTP/1.1
Authorization: Bearer eyJhbGci...
Accept: application/json
curl -H "Authorization: Bearer eyJhbGci..." \
-H "Accept: application/json" \
http://localhost:9000/services
Sessions
Browser sessions require HTTPS, and expire with the ID token or after 8 hours, whichever comes first.
Restarting the server signs everyone out unless you set a fixed signing key. Set
auth.oidc.session_key to a hex-encoded 32-byte value to keep sessions across restarts:
openssl rand -hex 32 # generate a 32-byte key
Pass it as a Docker secret rather than an inline value:
environment:
CETACEAN_AUTH_OIDC_SESSION_KEY_FILE: /run/secrets/session_key
secrets:
- session_key
The cookie stores subject, display name, email and groups, but no raw token claims. Grants read from an OIDC claim
(acl.oidc_claim) therefore reach bearer-token requests only; for browser users, match on
group: audiences in the policy instead.
Logout
POST /auth/logout clears the session cookie. If the IdP advertises an end_session_endpoint
(OpenID Connect RP-Initiated Logout 1.0), the user is also redirected there for sign-out.
IdP setup
- Create a client with
confidentialaccess type - Set the valid redirect URI to
https://cetacean.example.com/auth/callback - Enable “Standard Flow” (authorization code)
- Take the client ID and secret from the Credentials tab
- Create a “Regular Web Application”
- Add
https://cetacean.example.com/auth/callbackto Allowed Callback URLs - Add
https://cetacean.example.comto Allowed Logout URLs - Use the Auth0 domain as the issuer (for example
https://your-tenant.auth0.com)
Dex:
staticClients:
- id: cetacean
secret: your-secret
name: Cetacean
redirectURIs:
- https://cetacean.example.com/auth/callback
Tailscale
Identifies users through the Tailscale WhoIs API. Requests from tailnet peers are authenticated without a login flow. See Tailscale configuration for all parameters.
Choosing a mode
| Local mode (default) | tsnet mode | |
|---|---|---|
| How it works | Queries the host’s Tailscale daemon to identify peers | Embeds a Tailscale node inside the Cetacean process |
| Tailscale installed on host? | Yes, the daemon must be running | No |
| Network binding | Listens on server.listen_addr; only Tailscale addresses are authenticated | Serves the app over HTTPS on port 443 of the tailnet node |
| Docker health checks | Work normally, the health endpoint is auth-exempt | Work normally, /-/* meta endpoints stay on the regular listener |
| Config complexity | Minimal: -auth-mode tailscale | Needs an auth key, a hostname, and a persistent state directory |
| Best for | Hosts already running Tailscale (bare metal, VMs) | Containers, Swarm services, or hosts without Tailscale installed |
In local mode Cetacean still listens on all interfaces by default. Requests from outside the tailnet are
rejected, but the port is open to anything that can reach it. For real isolation, bind to the node’s Tailscale
address (-listen 100.x.x.x:9000) or use tsnet mode.
Local mode
Uses the local Tailscale daemon to identify peers, so Cetacean must run on a node inside the tailnet with access to
/run/tailscale/tailscaled.sock.
environment:
CETACEAN_AUTH_MODE: tailscale
volumes:
- /run/tailscale/tailscaled.sock:/run/tailscale/tailscaled.sock:ro
tsnet mode
Embeds a Tailscale node in the Cetacean process. No local Tailscale installation is needed. The full app is served over HTTPS on port 443 of the tailnet node, with the node’s own certificate for its MagicDNS name, so the tailnet must have MagicDNS and HTTPS certificates enabled. Startup brings the node up and fetches its certificate, and exits if either is off or the two together take longer than two minutes.
/-/health, /-/ready and /-/metrics stay on server.listen_addr in plain HTTP, at the root and under
server.base_path, so Docker health checks and a Prometheus on the overlay network keep reaching them. Nothing else
is served there.
tls.cert and tls.key are rejected at startup in tsnet mode. The OAuth server needs
server.public_url set to the node’s address, such as https://cetacean.example-tailnet.ts.net;
without it Cetacean refuses to start.
environment:
CETACEAN_AUTH_MODE: tailscale
CETACEAN_AUTH_TAILSCALE_MODE: tsnet
CETACEAN_AUTH_TAILSCALE_AUTHKEY_FILE: /run/secrets/ts_authkey
CETACEAN_AUTH_TAILSCALE_HOSTNAME: cetacean
CETACEAN_AUTH_TAILSCALE_STATE_DIR: /var/lib/cetacean/tsnet
secrets:
- ts_authkey
Groups from capabilities
Set auth.tailscale.capability to map Tailscale app capabilities to identity groups:
environment:
CETACEAN_AUTH_MODE: tailscale
CETACEAN_AUTH_TAILSCALE_CAPABILITY: example.com/cap/cetacean
Then grant that capability in your Tailscale ACL policy:
{
"grants": [
{
"src": ["group:admins"],
"dst": ["tag:cetacean"],
"app": {
"example.com/cap/cetacean": [
{
"groups": ["admin", "operators"]
}
]
}
}
]
}
Groups from multiple matching grants are merged and deduplicated. Malformed capability values are skipped.
Client certificates (mTLS)
Authenticates with mTLS client certificates. Standard X.509 certificates and SPIFFE X.509-SVIDs both work. See client certificate configuration for the CA setting.
environment:
CETACEAN_AUTH_MODE: cert
CETACEAN_AUTH_CERT_CA: /run/secrets/ca.pem
CETACEAN_TLS_CERT: /run/secrets/server.pem
CETACEAN_TLS_KEY: /run/secrets/server-key.pem
secrets:
- ca.pem
- server.pem
- server-key.pem
A client presenting a certificate that CA didn’t sign can’t connect. One presenting none can—so the container’s
health check works—but reaches only the endpoints exempt from authentication, such as /-/ready and the discovery
documents, and is refused with 403 everywhere else. Identity comes from the SPIFFE URI SAN, else the
Common Name, else the first email SAN—a certificate carrying none of the three is rejected, as is one carrying
more than one SPIFFE SAN. Groups come from Organizational Unit (OU) fields.
Behind a TLS-terminating proxy
A proxy that terminates TLS forwards the certificate it verified in the Client-Cert header, and
identity is built from it exactly as from a directly presented one:
environment:
CETACEAN_AUTH_MODE: cert
CETACEAN_TRUSTED_PROXIES: "10.0.0.0/8"
The proxy verifies the certificate against its own CA; auth.cert.ca configures Cetacean’s own
TLS listener and isn’t consulted here. A certificate presented directly always wins over the header, and
Client-Cert-Chain is ignored. One of the two—TLS here, or a trusted proxy—is required for cert mode to start.
The header is honored only from an address in server.trusted_proxies, so the
proxy must strip any Client-Cert its own clients send.
API access tokens
Every preceding mode establishes identity from something the deployment already trusts—a session cookie, an IdP token,
a client certificate, a proxy header. A script, a CLI or a native app often has none of those. With
oauth.enabled and oauth.api_tokens set, Cetacean is its own OAuth 2.1
authorization server and issues access tokens for the API, on any auth mode but none.
Send one as a bearer token:
GET /services HTTP/1.1
Authorization: Bearer eyJhbGci...
Accept: application/json
curl -H "Authorization: Bearer eyJhbGci..." \
-H "Accept: application/json" \
https://cetacean.example.com/services
A client obtains one the same way an MCP client does—discover, authorize with PKCE, exchange the code—and the MCP flow diagram applies unchanged. The difference is the resource it asks for. Getting the token is the client’s job; obtaining it takes you through whichever provider is configured, and a consent screen naming the client, so the token carries your identity and nothing more. Grants apply to it as they apply to your browser session. Grants from the provider—a tailnet capability or a proxy header—are captured when you approve the client, and its tokens keep them until it authorizes again.
A stream opened with a token—an event stream, a log tail, an MCP listen—closes when the token expires, after
oauth.access_token_ttl. The client reconnects with a fresh token; one it can no longer
refresh gets 401.
A client keeps the identity it was approved with—groups and provider grants included—for as long as it refreshes, up
to oauth.refresh_token_ttl. When someone leaves a group or loses access upstream, revoke
what they authorized:
curl -X DELETE https://cetacean.example.com/tokens/alice%40example.com
That needs operations level 3 and write on swarm:cluster. Their clients’ refresh tokens stop working at once and
each client must show the consent screen again; access tokens already issued, and the streams opened with them,
end within oauth.access_token_ttl.
Two resources, one server
The API and /mcp are separate protected resources with separate audiences:
| Resource | Identifier | Metadata document |
|---|---|---|
| Web API | the deployment root | /.well-known/oauth-protected-resource |
/mcp | the deployment root + /mcp | /.well-known/oauth-protected-resource/mcp |
A token for one is refused by the other, even though one path lies under the other. This is deliberate: approving an
agent for MCP isn’t approving it to delete your services. A client discovers which resource it’s talking to from
the resource_metadata parameter of the WWW-Authenticate header on a 401, and asks for that one by its RFC 8707
resource parameter.
With oauth.api_tokens off, the default, only /mcp is offered. The API resource then has no
metadata document and the authorize endpoint refuses to mint a token for it.
What a token is not
There are no personal access tokens and no scopes—both discovery documents say so with an empty
scopes_supported, and a client that asks for a scope anyway has it ignored rather than refused. The refresh token
is the long-lived credential: it rotates single-use, survives restarts under storage.data_dir,
and detects reuse. A token carries its user’s access, which its grants decide—so a token can’t hold
more than the person who authorized it, and the Allow header on every response reports what it may actually do.
cert mode is the exception worth planning around. A consent screen needs a browser that can present a client
certificate, which an in-app web view largely cannot. Such a client should authenticate to the API with its own
certificate and never touch the authorization server.
Trusted proxy headers
Reads identity from HTTP headers set by a reverse proxy (nginx, Traefik, Envoy).
auth.headers.subject names the header holding the subject and is required; the value must
be non-empty, free of control characters, and at most 256 bytes. See trusted proxy header
configuration for the optional name, email, and groups headers.
This mode trusts the proxy to set headers correctly. server.trusted_proxies is
required and restricts which source addresses may set identity headers, accepting individual IPs and CIDRs.
Without it Cetacean refuses to start.
The proxy must replace these headers, not append to them. A request that carries the subject, name, email, groups,
secret, or acl.headers_acl header more than once is refused, since one of the copies came from
the client. That check can’t see a proxy that joins its value onto the client’s within a single header, as in
admins,users: Cetacean reads that as one value the proxy set. Configure the proxy to drop or overwrite whatever
the client sent. A groups header may name at most 256 groups.
For defense in depth, require a shared secret on every proxied request:
environment:
CETACEAN_AUTH_MODE: headers
CETACEAN_AUTH_HEADERS_SUBJECT: X-Remote-User
CETACEAN_AUTH_HEADERS_SECRET_HEADER: X-Proxy-Secret
CETACEAN_AUTH_HEADERS_SECRET_VALUE_FILE: /run/secrets/proxy_secret
CETACEAN_TRUSTED_PROXIES: "10.0.0.0/8"
secrets:
- proxy_secret
Proxy examples
nginx with OAuth2 Proxy:
location / {
auth_request /oauth2/auth;
auth_request_set $user $upstream_http_x_auth_request_user;
auth_request_set $email $upstream_http_x_auth_request_email;
auth_request_set $groups $upstream_http_x_auth_request_groups;
proxy_set_header X-Remote-User $user;
proxy_set_header X-Remote-Email $email;
proxy_set_header X-Remote-Groups $groups;
proxy_set_header X-Proxy-Secret "my-secret-value";
proxy_pass http://cetacean:9000;
}
Traefik with ForwardAuth:
http:
middlewares:
auth:
forwardAuth:
address: "http://auth-server/verify"
authResponseHeaders:
- "X-Remote-User"
- "X-Remote-Email"
- "X-Remote-Groups"
routers:
cetacean:
middlewares:
- auth
service: cetacean
services:
cetacean:
loadBalancer:
servers:
- url: "http://cetacean:9000"
Behind Tailscale serve
tailscale serve can be the proxy, with Cetacean reading identity from the
Tailscale-User-* headers it sets and grants from the tailnet policy. Serve proxies from loopback, so trust only
that, and run it with accept-app-caps naming your capability so it forwards Tailscale-App-Capabilities:
[server]
trusted_proxies = "127.0.0.1/32"
[auth]
mode = "headers"
[auth.headers]
subject = "Tailscale-User-Login"
name = "Tailscale-User-Name"
email = "Tailscale-User-Login"
[acl]
policy = "grants: []"
headers_acl = "Tailscale-App-Capabilities"
headers_capability = "example.com/cap/cetacean"
Serve sends an object keyed by capability name; acl.headers_capability picks the grants
array out of it. Keep the empty acl.policy: without a policy, every authenticated identity has full
access and the capability grants change nothing. A tagged device gets no Tailscale-User-* headers, so it is refused
even when it holds the capability.
Forwarding headers
Behind a proxy listed in server.trusted_proxies, Cetacean reads the client address
and the external origin from forwarding headers. server.forwarded_headers names
which family it reads: x-forwarded (the default) for X-Forwarded-For, X-Forwarded-Proto and
X-Forwarded-Host, or forwarded for Forwarded. The other family is discarded on arrival.
Set this to match your proxy. nginx, HAProxy, Traefik and Caddy all write the x-forwarded family, and none
of them strip an inbound Forwarded—it’s an unknown request header they pass through untouched. A
deployment believing both families would let a client choose the address in its own audit log, and the host
in every URL Cetacean publishes.
Stripping the family your proxy doesn’t write costs nothing and is worth doing anyway:
proxy_set_header Forwarded "";
server.public_url settles the origin half outright: with it set, published URLs come
from configuration and no header can steer them.
TLS
TLS termination works in every auth mode except tsnet, which brings its own certificate, and is required for cert
mode. Set tls.cert and tls.key to enable HTTPS.
Deployment examples
The preceding fragments show only the settings each mode needs. These add the secrets, placement, and state volume
from Getting started. None publishes a port: reach Cetacean over the overlay network from a
reverse proxy, or add a ports: mapping as compose.yaml does. The tsnet example needs neither, since it serves
the app on the tailnet node itself.
OIDC with Keycloak
services:
cetacean:
image: ghcr.io/radiergummi/cetacean:latest
environment:
CETACEAN_AUTH_MODE: oidc
CETACEAN_AUTH_OIDC_ISSUER: https://keycloak.example.com/realms/myorg
CETACEAN_AUTH_OIDC_CLIENT_ID: cetacean
CETACEAN_AUTH_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_secret
CETACEAN_AUTH_OIDC_REDIRECT_URL: https://cetacean.example.com/auth/callback
CETACEAN_DATA_DIR: /data
secrets:
- oidc_secret
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- cetacean_data:/data
deploy:
placement:
constraints: [node.role == manager]
secrets:
oidc_secret:
external: true
volumes:
cetacean_data:
Tailscale (tsnet)
services:
cetacean:
image: ghcr.io/radiergummi/cetacean:latest
environment:
CETACEAN_AUTH_MODE: tailscale
CETACEAN_AUTH_TAILSCALE_MODE: tsnet
CETACEAN_AUTH_TAILSCALE_AUTHKEY_FILE: /run/secrets/ts_authkey
CETACEAN_AUTH_TAILSCALE_HOSTNAME: cetacean
# Without this tsnet picks its own directory and the volume below
# goes unused, so the node re-authenticates on every restart.
CETACEAN_AUTH_TAILSCALE_STATE_DIR: /var/lib/cetacean/tsnet
CETACEAN_DATA_DIR: /data
secrets:
- ts_authkey
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- tsnet-state:/var/lib/cetacean/tsnet
- cetacean_data:/data
deploy:
placement:
constraints: [node.role == manager]
secrets:
ts_authkey:
external: true
volumes:
tsnet-state:
cetacean_data:
Behind a proxy with header auth
services:
cetacean:
image: ghcr.io/radiergummi/cetacean:latest
environment:
CETACEAN_AUTH_MODE: headers
CETACEAN_AUTH_HEADERS_SUBJECT: X-Remote-User
CETACEAN_AUTH_HEADERS_EMAIL: X-Remote-Email
CETACEAN_AUTH_HEADERS_GROUPS: X-Remote-Groups
CETACEAN_TRUSTED_PROXIES: "10.0.0.0/8"
CETACEAN_DATA_DIR: /data
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- cetacean_data:/data
deploy:
placement:
constraints: [node.role == manager]
volumes:
cetacean_data:
Verifying your setup
GET /auth/whoami returns the identity the active provider produced:
GET /auth/whoami HTTP/1.1
curl -s http://localhost:9000/auth/whoami | jq .
GET /profile returns the same identity plus the effective grants. See the API reference for the
response schemas.