Skip to content
🐋Cetacean

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.

Fieldnoneoidctailscalecertheaders
subjectanonymoussub claimnumeric user IDSPIFFE URI SAN, else CN, else emailsubject header
displayNameAnonymousname, else preferred_usernameTailscale display nameCN, else the SPIFFE ID pathname header, else subject
email—email claimTailscale login namefirst email SANemail header
groups—groups claimapp capabilityOrganizational Unit (OU) valuesgroups header, comma-separated

Exempt paths

These paths skip authentication in every mode:

PathReason
/-/*, 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.

ModeRefusalChallenge
oidc401 AUT001Bearer
cert403 AUT005—
tailscale, headers403 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.

Note

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:

How OIDC picks a credentialA session cookie is tried first, then a bearer token, and only a request that accepts HTML is redirected to the identity provider.yesnovalidinvalidnoneyesnoRequestSessioncookie?AuthenticatedBearertoken?401 invalid_tokenAcceptsHTML?302 to the IdP401 Bearer
How OIDC picks a credentialA session cookie is tried first, then a bearer token, and only a request that accepts HTML is redirected to the identity provider.yesnovalidinvalidnoneyesnoRequestSessioncookie?AuthenticatedBearertoken?401 invalid_tokenAcceptsHTML?302 to the IdP401 Bearer

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.

The OIDC browser sign-in flowCetacean redirects an unauthenticated browser to the identity provider with PKCE, validates the callback, exchanges the code, and returns the browser to the page it asked for.GET /services (Accept: text/html)302, plus state, nonce, PKCE and redirect cookiesauthorize, with the S256 code challenge302 /auth/callback?code&state&issGET /auth/callbackexchange code with the PKCE verifierID tokensession cookie, 302 back to /servicesiss, state and nonce checked,after the flow cookies are clearedBrowserCetaceanIdentity providerBrowserCetaceanIdentity provider
The OIDC browser sign-in flowCetacean redirects an unauthenticated browser to the identity provider with PKCE, validates the callback, exchanges the code, and returns the browser to the page it asked for.GET/services(Accept:text/html)302, plusstate,nonce,PKCE andredirectcookiesauthorize, with the S256code challenge302/auth/callback?code&state&issGET/auth/callbackexchangecode withthe PKCEverifierID tokensessioncookie,302 backto/servicesiss, stateand noncechecked,after theflowcookies areclearedBrowserCetaceanIdentityproviderBrowserCetaceanIdentityprovider

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

Keycloak:

  1. Create a client with confidential access type
  2. Set the valid redirect URI to https://cetacean.example.com/auth/callback
  3. Enable “Standard Flow” (authorization code)
  4. Take the client ID and secret from the Credentials tab

Auth0:

  1. Create a “Regular Web Application”
  2. Add https://cetacean.example.com/auth/callback to Allowed Callback URLs
  3. Add https://cetacean.example.com to Allowed Logout URLs
  4. 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 worksQueries the host’s Tailscale daemon to identify peersEmbeds a Tailscale node inside the Cetacean process
Tailscale installed on host?Yes, the daemon must be runningNo
Network bindingListens on server.listen_addr; only Tailscale addresses are authenticatedServes the app over HTTPS on port 443 of the tailnet node
Docker health checksWork normally, the health endpoint is auth-exemptWork normally, /-/* meta endpoints stay on the regular listener
Config complexityMinimal: -auth-mode tailscaleNeeds an auth key, a hostname, and a persistent state directory
Best forHosts already running Tailscale (bare metal, VMs)Containers, Swarm services, or hosts without Tailscale installed
Warning

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.

Warning

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:

ResourceIdentifierMetadata document
Web APIthe deployment root/.well-known/oauth-protected-resource
/mcpthe 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.

Note

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.

Warning

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.

Warning

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.