23. Bearer token on /mcp and an identity-header setting#
Date: 2026-10-23
Status#
Accepted. Supersedes the “bearer token on /mcp deferred” item in the security options note,
and adds to 13. The MCP server is part of the app: streamable HTTP, add-only writes.
Context#
scrap has no login. On a tailnet the network is the gate; behind Cloudflare Access (or any
proxy that has to leave /mcp open to Claude Code) the proxy’s service token is the only gate
on /mcp, and whatever gets past it can read and change every page. The author of a change was
read from Tailscale-User-Login only, which says nothing useful behind another proxy.
Decision#
Optional bearer token on
/mcponly.SCRAP_MCP_TOKEN(comma separated) andSCRAP_MCP_TOKEN_FILE(one per line;#comments and blank lines skipped) give the accepted tokens. Unset: no check, so loopback and tailnet installs are unchanged. Set: a request to/mcpwithout a matchingAuthorization: Bearergets401andWWW-Authenticate: Bearer.It is ASGI middleware outside the app’s other middleware, so it runs before the
HostandOriginchecks and before any tool or body is read. The PWA, the JSON API and/healthzare not touched (probes need/healthz; the PWA and API stay behind the proxy).Comparison uses
hmac.compare_digestagainst every token, so timing does not say which matched. Tokens are never logged or echoed.Several tokens allow rotation without a gap and a token per client, revoked by removing it.
A set but empty value, or a missing or token-less file, stops scrap starting. Failing open would turn a mistyped secret mount into an unauthenticated
/mcp.Static tokens only. No token-issuing command and no tokens stored hashed in the database; that needs a migration and a UI for what is a handful of clients. Revisit if clients multiply.
No rate limiting or lock-out. Tokens are long random values, and the proxy in front (Cloudflare) is the place to throttle.
SCRAP_IDENTITY_HEADER(defaultTailscale-User-Login; blank counts as unset) names the one header read as the author, elsetagged. A proxy that sets another header (oauth2-proxy’sX-Auth-Request-User) is named there, and a forged copy of the default is then ignored. Proxies that do not strip client headers mean the proxy and NetworkPolicy must be the only way in; a forged value only changes the recorded author. ASCRAP_ALLOWED_USERcheck is left for later (since taken up as the login proxy’s token check, 45. scrap verifies the login proxy’s Keycloak token).Helm.
mcp.tokenSecret.{name,key}names an existing Secret and key, wired toSCRAP_MCP_TOKENbysecretKeyRef;identityHeadersets the header. The chart never takes a token as a value (the schema rejects it), and its tests check no secret renders as a value.
Consequences#
/mcphas a second layer behind the proxy’s. Claude Code sends the token in aheadersentry (it expands${VAR}there, so the token can stay in the environment).A token is shared by whoever holds it, and every MCP change is still author
claude.Claude.ai connectors, which cannot send a custom header, cannot reach a token-guarded
/mcp(an accepted limitation; no OAuth).Tokens are read at start: changing them means a restart.