Skip to content

Authentication

Two auth surfaces:

  • Browser sessions — WebAuthn passkey + cookie. The primary path.
  • Scripts / CLI — bearer token. Secondary path for curl / pluma auth … subcommands.

Both gate /api/* through the same middleware.

Passkey enrollment

WebAuthn registration ceremony, server-side state in server/auth.go.

Per-RPID grace

The middleware lets a request through unauthenticated when:

  1. require_auth = false (dev override), OR
  2. The request is from a loopback address AND loopback_auth_bypass = true, OR
  3. No credentials are enrolled for the current origin's RPID yet.

That third condition is the "first pair" grace. Without it the first user on a new origin couldn't reach the API to start the enrollment dance. The grace closes the moment the first credential is registered for that RPID.

Per-origin: enrolling on loopback doesn't gate the tailnet URL, and vice versa. Each origin starts in grace until the first enrollment lands.

RPID collapse on tailnet hosts

On *.ts.net hostnames, Pluma sets the WebAuthn RPID to the tailnet parent (e.g. pluma.alice.ts.netalice.ts.net). One passkey enrolled there covers every tailnet subdomain on that tailnet. The collapse only fires for .ts.net; LAN IPs and other hostnames stay at their full origin.

Sessions

Successful /api/auth/login/finish sets a session cookie:

  • __Host-pluma_sess (HTTPS) or pluma_sess (HTTP loopback)
  • HttpOnly, SameSite=Lax, signed with a per-install session key
  • 30 days inactivity timeout

The session key lives at <datadir>/session-key (created on first boot; 32 random bytes; mode 0o600). Reset by deleting the file — logs everyone out, all enrolled passkeys are still valid for a fresh login.

HSTS is set on HTTPS responses (max-age=31536000; includeSubDomains).

Bearer tokens

pluma auth login performs the WebAuthn ceremony against a running server, then calls POST /api/auth/token to mint a bearer token. The token lands at ~/.config/pluma/cli.token (mode 0o600).

Server-side tokens live in <datadir>/tokens.json (encrypted). Each token carries a label so you can revoke an individual script's access without rotating everything.

Use the token via Authorization: Bearer <token> header OR ?api_token=<token> query param. Order: header wins.

Revoke from Settings → Privacy → Enrolled passkeys (tokens are listed alongside; the UI surface is shared).

Middleware chain (auth-relevant)

withTrustedProxies → withLogging → withNoStoreOnUserContent
  → withAuth → withHostAllowlist → withCORS → mux

withAuth runs BEFORE withHostAllowlist because passkey enrollment needs to work from any origin (the allowlist might not include the new device yet). Once the user enrols a passkey, subsequent requests get auth'd; the allowlist still gates whether they reach the API at all from a non-allowed host.

Reset paths

Want to Do
Disable auth entirely (dev) require_auth = false in config
Bypass for the host machine loopback_auth_bypass = true (default)
Force re-enrollment Delete credentials.json (keeps everything else)
Logout everyone Delete session-key (or call POST /api/auth/logout per session)
Lose all bearer tokens Delete tokens.json