Gateway login gives each Claude Code user their own rotating OAuth session instead of distributing one shared client token. It is an opt-in surface: without [server.gateway], none of the OAuth or device-approval routes exist.
1. Configure the login surface
Create a signing secret of at least 32 bytes and a comma-separated list of email:secret approval users. Keep both in shunt’s environment, not in shunt.toml:
export SHUNT_GATEWAY_JWT_SECRET="$(openssl rand -base64 48)"
export SHUNT_GATEWAY_USERS='alice@example.com:<unique-secret>,bob@example.com:<unique-secret>'Add the public URL that Claude Code and users’ browsers can reach, and point [server.gateway.session] at the secret:
[server.gateway]
public_url = "https://gateway.example.com"
users_env = "SHUNT_GATEWAY_USERS" # default
trust_forwarded_for = false # default
# state_path = "~/.shunt/gateway-sessions.json" # default; "" = memory-only sessions
[server.gateway.session]
jwt_secret = "${SHUNT_GATEWAY_JWT_SECRET}"
ttl_hours = 1 # defaultStartup fails closed if public_url is not a bare HTTPS origin (http is allowed only on loopback), the token TTL is zero, the signing secret is shorter than 32 bytes, or neither a valid user list nor a valid external IdP is configured. A static-user secret may contain : because only the first colon separates an email from its secret.
The deprecated jwt_secret_env (env var name, default SHUNT_GATEWAY_JWT_SECRET) and token_ttl_seconds (default 3600) keys remain fully supported when used alone, and token_ttl_seconds is still the only way to set a sub-hour lifetime. Combining a deprecated key with its session.* replacement fails startup (jwt_secret_env with session.jwt_secret, or token_ttl_seconds with session.ttl_hours); shunt logs a deprecation warning whenever a deprecated key is explicitly set — whether in the config file or through a SHUNT_* environment override — and stays silent only when the key itself is never configured (relying on the SHUNT_GATEWAY_JWT_SECRET env var to hold the secret, without ever setting jwt_secret_env, still doesn’t warn). See the configuration reference for the full precedence rules and the secret-rotation procedure.
Use Google OIDC instead
Create an OAuth web client in Google Cloud with this exact authorized redirect URI:
https://gateway.example.com/device/callbackPut its secret in the gateway environment, then configure the issuer and a mandatory allowlist:
export SHUNT_GATEWAY_OIDC_SECRET='<google-client-secret>'[server.gateway.oidc]
issuer = "https://accounts.google.com"
client_id = "<google-client-id>"
client_secret_env = "SHUNT_GATEWAY_OIDC_SECRET" # default
allowed_domains = ["example.com"]
# allowed_emails = ["contractor@outside.example"]Google uses the default openid email profile scopes. shunt requires Google UserInfo to return email_verified = true, then admits the user only when the case-insensitive full email or domain matches the allowlist.
For GitHub, SAML, or another provider that does not expose the standard OIDC surface shunt expects, put an OIDC identity provider such as Dex in front of it and configure the Dex issuer here. Direct provider-specific OAuth2 integrations are out of scope.
The issuer and every endpoint must use HTTPS. Plain HTTP is accepted only at localhost or 127.0.0.1: those are the only loopback hosts the approval page’s Content Security Policy can name, so an IdP on [::1] or another 127.0.0.0/8 address is refused at startup rather than blocked later in the browser.
At least one non-empty allowed_domains or allowed_emails entry is required;
shunt refuses to start without it. users_env becomes optional when
[server.gateway.oidc] is configured. Leave SHUNT_GATEWAY_USERS set to show
both SSO and password sign-in, or unset it to show only the provider button.
Use HTTPS for every non-loopback deployment. By default, /device ignores X-Forwarded-For and X-Real-IP and rate-limits the socket peer. If shunt is reachable exclusively through a trusted reverse proxy, set trust_forwarded_for = true and configure that proxy to remove client-provided forwarding headers before setting its own trusted client address. Never enable this option on a directly exposed gateway.
2. Push managed Claude Code login settings
Set these managed settings on each developer machine:
{
"forceLoginMethod": "gateway",
"forceLoginGatewayUrl": "https://gateway.example.com"
}Managed settings locations depend on the platform:
- macOS:
/Library/Application Support/ClaudeCode/managed-settings.json - Linux and Windows (WSL):
/etc/claude-code/managed-settings.json - Windows native:
C:\Program Files\ClaudeCode\managed-settings.json
The URL must equal public_url. Claude Code reads the OAuth endpoint paths from shunt’s discovery document. The issued bearer gates /v1/models and inference requests whose selected provider injects a server-side credential; passthrough providers remain open.
3. Sign in
Start Claude Code and run /login. The CLI shows a device code and opens the gateway’s /device page. On that page:
- Confirm the displayed device code.
- Select the SSO button (Sign in with Google for Google, Sign in with SSO for other providers), then finish the provider login. If static users are also configured, entering an email and secret and selecting Approve device remains available.
- Return to Claude Code after the success page appears.
Pre-filling the code never auto-approves it. The password approval POST is same-origin protected: the gateway trusts the browser’s Sec-Fetch-Site: same-origin Fetch Metadata signal, because the page’s Referrer-Policy: no-referrer makes the browser send Origin: null on its own form submission. A reverse proxy that strips Sec-Fetch-* headers therefore blocks every approval with “This request came from another site and was blocked.”. The external callback instead binds the cross-site redirect with a single-use, ten-minute OAuth state and PKCE; provider errors are never echoed into the page.
Sign in from the terminal instead
forceLoginMethod: "gateway" is not the only way to reach the gateway with a per-user identity. shunt ships the client side of the same device flow, so a user can sign in from a terminal and keep Claude Code out of a signed-in gateway session:
shunt gateway login https://gateway.example.com # same /device approval, in a browser
shunt gateway claude # launch Claude Code wired to the gatewayshunt gateway login stores the issued session at ~/.shunt/gateway/session.json (owner-only; override with SHUNT_GATEWAY_SESSION_FILE). shunt gateway claude then launches Claude Code with an inline --settings document supplying the gateway base URL and an apiKeyHelper of shunt gateway token, scoped to that one process — ~/.claude/settings.json is never modified. shunt gateway logout discards the session. Full flags in the CLI reference.
What this changes, and what it does not:
In-client /login (forceLoginMethod) |
shunt gateway login + shunt gateway claude |
|
|---|---|---|
| Browser approval | Required | Required — same /device page, same same-origin protection |
| Per-user identity at the gateway | Yes | Yes, same device-flow session |
| Client-side feature trade-offs | Taken on | Not taken on — the credential arrives via apiKeyHelper, which leaves the client in its ordinary first-party mode |
| Model aliases | A gateway session pins opus/sonnet to older ids |
Unchanged from a normal session |
| Credential-type gate | Tripped | Also tripped — supplying an apiKeyHelper is what triggers it, independently of the session gate |
Per-user policy from GET /managed/settings |
Delivered | Not observed being fetched — use forceLoginMethod: "gateway" when the client must enforce policy |
Which credential slot the token arrives in is what selects the client’s provider mode; the rows above were measured against Claude Code 2.1.234 and can change with a client release.
Managed settings and model policy
After sign-in, shunt serves the user’s resolved policy from authenticated
GET /managed/settings. Configure ordered [[server.gateway.policies]] entries:
[[server.gateway.policies]]
[server.gateway.policies.match]
emails = ["alice@example.com"]
[server.gateway.policies.cli]
availableModels = ["claude-opus-4-8"]
[server.gateway.policies.cli.env]
DISABLE_UPDATES = "1"
[[server.gateway.policies]]
match = {} # catch-all
[server.gateway.policies.cli.permissions]
deny = ["WebFetch"]All catch-all entries merge in order. The first email-specific match then merges
on top. Objects merge recursively, allow-list arrays replace, and arrays whose
key contains deny are unioned without duplicates. A configured policy always
returns 200; when no user-specific or catch-all settings apply, the response
contains only the injected telemetry env if telemetry is enabled, and {}
otherwise. Omitting policies returns 404 so Claude Code can distinguish “no
managed policy.” Responses include a stable
per-user uuid, a settings checksum, and an RFC-quoted ETag containing that
checksum; If-None-Match returns 304 when unchanged and also accepts weak,
comma-list, wildcard, and legacy-unquoted validators.
When availableModels resolves to an array of strings, shunt also enforces it on
/v1/messages and /v1/messages/count_tokens for that gateway user. It strips
one trailing Claude Code context-window hint ([1m] or [1M]) from the
client-requested model before comparison, so allowed[1m] matches an allowed
entry. A denied model receives 400 invalid_request_error without contacting
the upstream.
Telemetry ingest
A telemetry destination list with at least one opted-in signal does two things
at once. It pushes the telemetry enable flag plus five OTEL_* environment
values through managed settings — each signal’s exporter set to otlp when
some destination opts in to it and none otherwise, and
OTEL_EXPORTER_OTLP_ENDPOINT set to your public_url —
which points every managed client’s exporter at the gateway. And it turns on
verbatim relay for the inbound routes those clients then post to:
POST /v1/metrics, POST /v1/logs, and POST /v1/traces — registered
whenever [server.gateway] is enabled, and accept-and-discard until a
destination opts in. Policy env keys still override injected defaults.
[server.gateway.telemetry]
[[server.gateway.telemetry.forward_to]]
url = "https://collector.example.com"
# metrics = true # default
# logs = false # default
# traces = false # default
# headers = { "x-api-key" = "..." }url is a base OTLP endpoint, the same shape as
OTEL_EXPORTER_OTLP_ENDPOINT. shunt trims a trailing / and appends the signal
path, so the destination above receives
https://collector.example.com/v1/metrics.
Each destination opts in per signal. Metrics default on; logs and traces default
off, because Claude Code log records and spans can carry command lines, prompts,
and file paths, so sending them off-host should be a deliberate choice. Set
logs = true or traces = true on the destinations that should receive them.
The ingest routes require the same gateway bearer as /managed/settings;
static [server.auth] tokens do not authenticate them. Payloads are relayed
verbatim — the exact request bytes, with the inbound content-type and
content-encoding preserved and the destination’s configured headers applied
over them (a configured key replaces the forwarded value rather than duplicating
the header) — so protobuf and JSON exporters both work and Claude Code’s
client-side attribution attributes survive untouched. The client’s
Authorization header is never forwarded to a collector, and relays do not
follow redirects.
The response is always an immediate 200: relays run as detached tasks, so a
slow or unreachable collector never becomes client-visible latency, and a signal
that no destination opted in to is accepted and discarded rather than rejected.
A body over the 32 MiB inbound cap returns 413. At most 64 relays are in
flight at once; past that a payload is dropped with a warning rather than
queued, so saturation never becomes latency the client can feel.
url must be a base endpoint — scheme, host, and optional path. A query string,
fragment, or embedded user:password is rejected at startup, because shunt
appends the signal path to it and a URL-embedded credential would reach logs.
For what Claude Code actually exports on each signal, see Claude Code monitoring.
Feature trade-offs
forceLoginMethod: "gateway" puts Claude Code into a signed-in Claude apps gateway session. That is
a different and much larger gate than pointing ANTHROPIC_BASE_URL at shunt, and the restrictions
below are enforced by the client — shunt cannot lift them by supporting the feature upstream. Each
row states the outcome Anthropic documents; follow the link for the reasoning.
| What changes | Documented behavior |
|---|---|
| Server-side web search | Not available. “The CLI can’t see which upstream provider the gateway routes to, so it can’t verify web search support and disables WebSearch on gateway sessions” (Claude apps gateway). |
| 1-hour prompt cache TTL | Not available. “The CLI omits the extended-cache-ttl beta on gateway sessions, because not every upstream the gateway can route to supports the 1-hour TTL, so prompt caching through the gateway uses the 5-minute TTL” (same page). Whether ENABLE_PROMPT_CACHING_1H overrides that on a gateway session is not documented either way — don’t plan around it. |
| First-party-only optimizations | Not available. Global cache scope and token-efficient tools are among the betas “the CLI doesn’t enable … on gateway sessions” (same page). |
| Other credentials | Ignored. “The gateway token is the session’s only credential. ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY, apiKeyHelper, and any earlier claude.ai login are ignored while signed in” (same page). |
| Unattended / CI sign-in | Not possible. “There is no service-token flow for unattended pipelines. Gateway sign-in always runs the browser device flow” (same page). The device flow does work over SSH: approve on a laptop browser, poll on the remote host. |
| Startup when the gateway is down | Fail-closed. “Signed-in sessions exit at startup with an error after about 10 seconds when the gateway is unreachable, rather than starting without their settings” (same page). |
| Telemetry transport | OTLP over HTTP only; OTLP/gRPC is “not supported” (same page). This matches shunt’s own OpenTelemetry surface. |
| Publishing artifacts | Not possible. “Sessions using an API key, gateway token, or cloud-provider credential cannot publish” (Artifacts). |
| Analytics and feedback to Anthropic | Off, with no opt back in. “On a signed-in Claude apps gateway session, usage analytics, error reporting, and survey ratings to Anthropic are disabled by the gateway credential itself, with no setting to re-enable them” (Data usage). |
| Server-managed settings | Not delivered. “Server-managed settings delivery requires a direct connection to api.anthropic.com, so it does not reach gateway-routed sessions. Gateway deployments use this file-based managed settings path” (rollout guide). shunt’s own GET /managed/settings is the per-user policy channel instead. |
| Auto mode | Available, but model-restricted. CLAUDE_CODE_ENABLE_AUTO_MODE is no longer needed — it is now “accepted for compatibility with older releases and has no effect” (Environment variables). On gateway sessions “only Claude Sonnet 5, Opus 4.7 or later, and Fable 5” support it (Permission modes), so a policy that grants only older ids removes auto mode from the Shift+Tab cycle. |
Session behavior
Access tokens are HS256 JWTs with a one-hour default lifetime. Claude Code silently refreshes them. Every refresh rotates the opaque refresh token; replaying a retained old token within the 30-day, 64-tombstone bound invalidates the active token in that rotation family and makes Claude Code sign in again.
Device grants and attempt counters live in memory. Refresh-token sessions survive config hot reload and are persisted by default as described below. Changes to the signing secret, user list, and OIDC configuration hot-apply. Expired grants and idle rate-limit entries are removed opportunistically; device grants and rate-limit identities are each capped at 4,096 entries. Used refresh-token tombstones are retained for 30 days and capped at 64 per family, and an active session that goes 30 days without refreshing expires. Adding or removing the [server.gateway] table itself requires a restart because route registration is fixed at boot.
Refresh sessions survive a shunt restart by default: shunt writes the refresh-token store to state_path (default ~/.shunt/gateway-sessions.json, atomically, owner-only permissions (0600 on Unix)) after every grant or rotation and restores it at boot, so users keep refreshing instead of re-running the browser flow. Refresh tokens are stored as SHA-256 hashes — the file never contains a usable credential, only token hashes and the signed-in identities. A missing or corrupt file just falls back to memory-only behavior, as does an environment with no resolvable home directory. Set state_path = "" for memory-only sessions, where a restart clears refresh sessions and users sign in again once their access JWT expires. Device grants stay memory-only either way (a restart mid-login only costs that attempt), and the state file must not be shared between concurrently running shunt processes.
Note that refresh grants mint tokens from the identity stored with the session and do not re-check the static user list or external IdP allowlist, so removing a user from either approval source does not end an existing session. To deprovision a user immediately, also delete the state file (or set state_path = "") and restart.
When [server.auth] and [server.gateway] are both configured, they compose: either a valid static client token or a valid gateway bearer grants access. This supports a staged migration without breaking existing clients.
What comes next
Managed policy, ETag caching, telemetry environment push, inbound OTLP
telemetry ingest, and server-side model allow-list enforcement are described
above.