MCP OAuth Authorization for Deployed Photons
Status: V1 implementation plan for agent review
This document defines Photon's deployed MCP authorization plan. It turns the shared bearer lock into a proper OAuth/JWT resource-server model without making every photon author implement cryptography.
Problem
PHOTON_MCP_BEARER is useful as a minimal transport lock, but it is a shared secret. Possession is full authority:
- leaked bearer tokens are immediately usable by any caller
- every agent shares the same identity unless the photon adds its own layer
- revocation means rotating the secret and updating every legitimate agent
- the deployed server stores the same secret needed to authenticate requests
For deployed photons, the resource server should not hold a signing secret. It should hold only public verification material and accept short-lived, scoped access tokens issued by an authorization server.
Target Model
Photon follows the MCP/OAuth split:
- Photon runtime / Worker: OAuth protected resource server. It owns MCP tools, verifies access tokens, populates caller identity, and enforces scopes.
- Photon authorization server: OAuth authorization server. It owns signing keys, client/agent registrations, consent decisions, and token issuance.
- Photon operator: deploys the photon and runs or configures the authorization server for that deployment.
- Tenant/resource owner: approves which agents may act inside their tenant and with which scopes.
- Agent: OAuth client. It requests authorization, receives an access token, and calls the Photon MCP endpoint with that token.
Single-tenant self-hosted deployments collapse the operator and tenant owner into one person. Multi-tenant deployments keep them separate: the operator runs the app and auth server, while each tenant owner approves access only for their tenant.
V1 Decisions
V1 is intentionally smaller than the full tenant approval-server vision. It is the minimum secure slice that eliminates shared bearer as the preferred hosted agent mechanism.
- V1 auth mode is local issuer.
photon auth initcreates a local ES256 keypair and issuer metadata.photon auth tokensigns short-lived JWT access tokens from that local issuer. The full browser approval server remains V2. - JWT enforcement is explicit opt-in in V1.
photon host deploy cf <name> --mcp-auth jwtdetects~/.photon/auth/<name>/issuer.jsonand embeds only public verification material into the generated Worker. A deploy without--mcp-auth jwtmust not silently replace an existing bearer-protected deployment. - JWKS is a Worker variable in V1. The public key is not secret. Store compact JWKS JSON in
PHOTON_MCP_JWT_JWKS. This variable is generated by deploy tooling and is not meant to be hand-authored in normal use. AddPHOTON_MCP_JWKS_URLlater for hosted auth servers and key rotation without redeploy. - The token claim contract matches existing
JwtServiceconventions. Usetenant_idandclient_id, nottenantandowner, in V1 tokens. Use resource-boundaudequal to the deployed MCP endpoint URL for deployed MCP tokens, but do not change existing SERV authorization-server token defaults. - Tool scopes are method-level and deterministic by default. A method-level
@scope a bmeans the token must contain bothaandb. If a tool has no explicit@scope, Photon infers<toolName>:readfor@readOnlymethods and<toolName>:writefor all other methods. - Revocation V1 is short TTL only. Default token TTL is 15 minutes. No revocation list or introspection is required in V1. V2 can add revocation, introspection, and consent records.
- Auth mode is selected before token verification.
PHOTON_MCP_AUTH_MODE=jwtrequires a valid JWT.bearerpreserves existingPHOTON_MCP_BEARERbehavior.openpreserves dev/open behavior. When no mode is set, Photon derives the legacy behavior from existing bearer/open configuration and must not auto-enable JWT only because local issuer files exist.
Non-Goals
- Do not make every user-declared
@get/@postroute require MCP auth. Public web routes remain app-owned HTTP ingress and need explicit route auth. - Do not require every photon author to implement OAuth/JWT verification. Photon runtime owns transport-level MCP auth.
- Do not remove
PHOTON_MCP_BEARERin V1. It remains a dev/simple fallback and migration bridge. - Do not build the full multi-tenant approval UI in V1.
V1 User Flow
photon auth init appointments
photon host deploy cf appointments \
--mcp-auth jwt \
--mcp-audience https://appointments.example.com/mcp
photon auth token appointments \
--agent scheduler \
--audience https://appointments.example.com/mcp \
--scope bookings:read \
--ttl 15mThe generated token is passed to an agent, which calls MCP with:
Authorization: Bearer <jwt>The deployed Photon verifies the JWT and only then allows MCP tool execution.
V1 File Layout
photon auth init appointments creates:
~/.photon/auth/appointments/private.jwk
~/.photon/auth/appointments/public.jwk
~/.photon/auth/appointments/jwks.json
~/.photon/auth/appointments/issuer.jsonThe private key stays on the deployer/auth-server machine. It is never embedded in the Worker, deployment bundle, or photon source.
The CLI must create ~/.photon/auth/<name> with owner-only permissions where the platform supports them. private.jwk must be written with owner-only file permissions/ACLs, must not be overwritten unless the user requests key rotation, and must never be printed in deploy output, logs, dry-run output, or error messages. Generated access tokens are bearer credentials and should be printed only by the explicit photon auth token ... command.
issuer.json should contain:
{
"issuer": "photon-local:appointments",
"algorithm": "ES256",
"kid": "appointments-2026-05-22",
"defaultTtlSeconds": 900
}V1 Deploy Configuration
photon host deploy ... publishes only public verification config:
PHOTON_MCP_AUTH_MODE=jwt
PHOTON_MCP_JWT_ISSUER=photon-local:appointments
PHOTON_MCP_JWT_AUDIENCE=https://appointments.example.com/mcp
PHOTON_MCP_JWT_JWKS={"keys":[...public keys...]}For Cloudflare, these should be Worker vars rather than secrets. The public JWKS is intentionally public verification material. PHOTON_MCP_JWT_JWKS is a machine-generated compact JSON value copied from ~/.photon/auth/<name>/jwks.json; users normally should not edit it by hand. Manual overrides are supported for CI or hosted auth-server mode, but deployment tooling must document the required shell/Wrangler escaping. The deployer can still override the verification source manually for hosted auth-server mode:
PHOTON_MCP_JWKS_URL=https://auth.example.com/.well-known/jwks.json
PHOTON_MCP_AUTH_MODE=jwt
PHOTON_MCP_JWT_ISSUER=https://auth.example.com
PHOTON_MCP_JWT_AUDIENCE=https://appointments.example.com/mcpPHOTON_MCP_JWKS_URL is V2 unless an implementation can add it without delaying V1. If both inline JWKS and URL are configured, inline JWKS wins in V1.
V1 Token Shape
Access tokens are JWTs signed by the local issuer using ES256:
{
"iss": "photon-local:appointments",
"sub": "agent:scheduler",
"aud": "https://appointments.example.com/mcp",
"tenant_id": "default",
"client_id": "scheduler",
"scope": "bookings:read availability:write",
"iat": 1779430000,
"nbf": 1779430000,
"exp": 1779430900,
"jti": "tok_abc123"
}tenant_id defaults to default for single-tenant photons. Multi-tenant photons must pass or derive a tenant context before relying on tenant-bound authorization.
CLI flag to claim mapping:
--agent schedulersetsclient_idtoscheduler--agent schedulersetssubtoagent:scheduler--tenant tenant_123setstenant_idtotenant_123; when omitted it defaults todefault- repeated
--scopeflags and space-delimited--scopevalues are normalized into one space-delimited OAuthscopeclaim
The --agent value is a stable client/agent identifier from the operator's point of view. It is not a human display name. V2 may add explicit client registration; V1 keeps the mapping local and deterministic.
Required checks in Photon:
- signature verifies against configured JWKS/public key
- JOSE
algisES256 - JOSE
kidmaps to a key in the configured JWKS issmatchesPHOTON_MCP_JWT_ISSUERaudmatchesPHOTON_MCP_JWT_AUDIENCEexp,nbf, andiatare valid with a 60-second clock-skew allowancescopepermits the tool calltenant_idmatches the tenant context when the photon declares or supplies one; otherwisedefaultis accepted for single-tenant deployments
Compatibility requirement: existing JwtService.generateAccessToken() behavior must remain backward compatible for the SERV authorization server. If deployed MCP needs a different audience, add an explicit audience option or a deployed-MCP-specific token helper instead of changing the existing default audience contract.
Runtime Contract
Auth mode:
if PHOTON_MCP_AUTH_MODE == "jwt":
require valid OAuth access token for MCP user-code execution
else if PHOTON_MCP_AUTH_MODE == "bearer":
require shared bearer for MCP user-code execution
else if PHOTON_MCP_AUTH_MODE == "open":
preserve current open/dev behavior
else if PHOTON_MCP_BEARER exists:
preserve legacy shared bearer behavior
else:
preserve legacy open/dev behaviorJWT config must fail closed. If PHOTON_MCP_AUTH_MODE=jwt is set and any required JWT config is missing, malformed, unparseable, or unusable, the Worker must reject MCP user-code execution and local servers should fail startup where possible. Bearer fallback is allowed only when JWT auth mode is not active.
Deploy tooling may detect local issuer metadata and suggest --mcp-auth jwt, but it must not silently enable JWT enforcement for an existing deployment. If a deployment currently has PHOTON_MCP_BEARER and the user requests --mcp-auth jwt, deploy output and dry-run output must warn that existing bearer clients will stop working unless the user explicitly chooses a separate migration mode. A temporary dual-accept migration mode may be added later, but it must be explicit and time-bounded.
Discovery/handshake methods may remain unauthenticated where required by MCP, but user-code execution must be protected. This includes:
tools/call- async task creation paths that execute photon tools
- any Photon-owned browser/dev bridge that invokes tools
User-owned HTTP routes and webhooks use route-specific auth such as signed tokens, @webhook-auth, or future @route-auth.
Verified auth context should be available to photon code:
this.caller = {
id: "agent:scheduler",
name: undefined,
anonymous: false,
scope: "bookings:read availability:write",
claims: {
iss: "photon-local:appointments",
sub: "agent:scheduler",
aud: "https://appointments.example.com/mcp",
tenant_id: "default",
client_id: "scheduler"
}
}JWT-backed callers must preserve the existing public CallerInfo shape: { id: string; name?: string; anonymous: boolean; scope?: string; claims?: Record<string, unknown> }. Adding JWT claims must not remove or narrow fields documented for @auth callers or exposed in editor type support.
For Cloudflare deployments, this.mcpAuthed remains a boolean compatibility signal. It is true when the active MCP execution passed either JWT or bearer auth.
Tool Scope Policy
Photon adds a scope annotation for every tool:
/**
* @auth required
* @scope availability:write
*/
async setAvailability(...) {}Rules:
- no
@scope: Photon infers<toolName>:readfor@readOnlymethods and<toolName>:writefor all other methods - one
@scope: token must contain that scope - multiple scopes on one tag, such as
@scope a b, require every listed scope - repeated tags, such as
@scope aand@scope b, also require every listed scope @scopeis method-level only- scope names follow OAuth scope-token syntax: visible ASCII excluding spaces and double quotes; prefer lower-case names with
:separators, such asbookings:read - scopes are represented as space-delimited OAuth scopes in the JWT
scopeclaim @audience userremains a UI visibility hint, not an auth boundary@internalremains a discovery/visibility control, not an auth boundary
Scope enforcement must happen after token verification and before invoking user code.
Protected Resource Metadata
Hosted OAuth deployments with an authorization-server URL should expose standard protected resource metadata:
GET /.well-known/oauth-protected-resourceHosted response:
{
"resource": "https://appointments.example.com/mcp",
"authorization_servers": ["https://auth.example.com"],
"bearer_methods_supported": ["header"],
"scopes_supported": ["bookings:read", "availability:write"]
}Local-issuer V1 is a pre-provisioned JWT mode, not a discoverable standards-compliant OAuth flow. Because it has no network authorization server, it must not expose /.well-known/oauth-protected-resource as if clients can discover and complete OAuth authorization from it. Local issuer mode may expose a Photon-specific diagnostic endpoint instead:
{
"resource": "https://appointments.example.com/mcp",
"photon_local_issuer": "photon-local:appointments",
"bearer_methods_supported": ["header"],
"scopes_supported": ["bookings:read", "availability:write"]
}Unauthorized MCP requests in hosted OAuth JWT mode should return 401 with a WWW-Authenticate header that includes resource_metadata:
WWW-Authenticate: Bearer realm="photon", resource_metadata="https://appointments.example.com/.well-known/oauth-protected-resource", error="invalid_token"Local-issuer pre-provisioned JWT mode must not include resource_metadata because it intentionally does not expose standard OAuth protected-resource metadata:
WWW-Authenticate: Bearer realm="photon", error="invalid_token"Auth failures during JSON-RPC MCP requests must also use a stable JSON-RPC error envelope:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32001,
"message": "Unauthorized",
"data": {
"reason": "missing_token"
}
}
}Stable JWT-mode data.reason values:
missing_tokenmalformed_tokenunsupported_algunknown_kidbad_signatureexpired_tokentoken_not_yet_validwrong_issuerwrong_audiencetenant_mismatch
Use WWW-Authenticate error="invalid_token" for invalid/missing JWTs and HTTP 401.
Valid JWTs that lack required scopes must return HTTP 403 with WWW-Authenticate error="insufficient_scope" and include the required scope where known:
WWW-Authenticate: Bearer realm="photon", error="insufficient_scope", scope="availability:write"The JSON-RPC error envelope for insufficient scope should use code -32003, message Forbidden, and data.reason = "insufficient_scope". User code must not run for any auth or authorization error.
Bearer mode must preserve the existing PHOTON_MCP_BEARER error and WWW-Authenticate: Bearer realm="photon" contract when JWT mode is absent. Do not change bearer error strings or headers as part of V1 unless the change is explicitly versioned.
The authorization server should expose standard OAuth metadata and JWKS in V2:
GET /.well-known/oauth-authorization-server
GET /.well-known/jwks.jsonCross-Platform CLI Requirements
The CLI must work on macOS, Linux, and Windows. It must not shell out to openssl.
Use TypeScript and WebCrypto/Node crypto for:
- key generation
- JWK/JWKS import/export
- JWT signing
- JWT verification tests
Use ES256 in V1. Ed25519/EdDSA can be added later if support is consistent across Node, Bun, and Cloudflare Workers.
Implementation Boundaries
Representative files for V1:
src/cli/index.ts: registerphoton auth ...command groupsrc/cli/commands/auth.ts: implementinit,token, andverifysrc/serv/auth/jwt.ts: reuse or extend ES256 JWT/JWK helpers; preserve existing SERV token defaults and add an explicit deployed-MCP audience pathsrc/serv/auth/well-known.ts: reuse protected-resource metadata helperssrc/shared/http-route-extractor.tsor schema extraction path: extract@scopetemplates/cloudflare/worker.ts.template: verify JWT before MCP user-code execution and expose protected-resource metadatasrc/server.tsor streamable HTTP transport path: apply the same JWT/scope checks for local/Beam MCP executionsrc/deploy/cloudflare.ts: detect local issuer metadata and publish public JWT config into generated Worker vars only when--mcp-auth jwtor equivalent explicit config is presenttests/cf-mcp-bearer.test.tsor a newtests/cf-mcp-jwt.test.ts: cover Cloudflare JWT behavior
Do not implement authorization by adding guards inside individual photon tool methods. Tool invocation policy belongs in the runtime.
Verification Matrix
Unit tests:
- ES256 keypair generation creates private/public JWK and JWKS with
kid - token signing emits
iss,sub,aud,tenant_id,client_id,scope,iat,nbf,exp, andjti - verifier accepts a valid token
- verifier rejects missing token
- verifier rejects malformed JWT
- verifier rejects unsupported
alg - verifier rejects unknown
kid - verifier rejects bad signature
- verifier rejects expired token
- verifier rejects token before
nbf - verifier rejects wrong issuer
- verifier rejects wrong audience
- scope checker requires all listed
@scopevalues
Cloudflare/runtime integration tests:
- deployed Worker with JWT config rejects unauthenticated
tools/call - deployed Worker with JWT config accepts valid JWT
tools/call - deployed Worker with JWT config rejects wrong scope before user code runs
- deployed Worker with JWT config sets
this.callerandthis.mcpAuthed - deployed Worker with hosted authorization-server URL exposes protected-resource metadata
- local-issuer JWT mode does not expose standard protected-resource metadata as if it were a discoverable OAuth flow
- local-issuer JWT mode omits
resource_metadatafromWWW-Authenticate - hosted OAuth JWT mode includes
resource_metadatainWWW-Authenticate - deployed Worker with JWT config returns
WWW-Authenticateon auth failure - bearer fallback still works when JWT config is absent
- partial or malformed JWT config fails closed when JWT auth mode is active
- JWT mode is opt-in and deploy/dry-run warns before replacing existing bearer auth
- bearer error/header behavior remains byte-compatible when JWT mode is absent
- public user-declared
@get/@postroutes remain unaffected
End-to-end CLI smoke:
photon auth init appointments
photon host deploy cf appointments \
--mcp-auth jwt \
--mcp-audience https://appointments.example.com/mcp \
--dry-run
photon auth token appointments \
--agent scheduler \
--audience https://appointments.example.com/mcp \
--scope bookings:read \
--ttl 15m
photon auth verify appointments <token> --audience https://appointments.example.com/mcpFor a real Worker smoke test, call /mcp with and without the generated JWT and assert the expected 200/401 behavior.
V2: Tenant Approval Server
The full OAuth approval flow remains the target model for multi-tenant hosted photons:
Agent wants MCP access
-> discovers protected-resource metadata from Photon
-> starts OAuth authorization with the configured authorization server
-> requests resource + tenant + scopes
Tenant owner reviews request
-> approves, narrows, or rejects requested scopes
Authorization server issues short-lived signed JWT access token
Agent calls Photon MCP
-> Authorization: Bearer <jwt>
Photon verifies token and enforces tenant/scope before tool executionAn OAuth server operated by the Photon owner stores pending approval requests. A tenant owner approves or rejects each request:
photon auth requests --tenant tenant_123
photon auth approve req_123 --scope "bookings:read availability:write" --ttl 15m
photon auth deny req_124The approval record should include:
- resource/MCP endpoint
- tenant id
- owner id
- agent/client id
- requested scopes
- approved scopes
- expiration / max token TTL
- audit timestamps
For browser-based OAuth, this maps to a consent screen. For CLI/local issuer mode, the same state can be reviewed through CLI commands.
Multi-Tenant Enforcement
For multi-tenant photons, token verification is not enough. The app/runtime must also bind requests to a tenant:
token.tenant_id == requested tenant context
token.aud == this Photon MCP resource
token.scope permits this toolThe tenant context can come from:
- explicit MCP arguments
- instance routing
- hostname/subdomain
- path segment
@auth cf-accessor another upstream identity mode
V1 accepts tenant_id=default for single-tenant deployments. V2 must make tenant binding hard to forget for common multi-tenant patterns.
Relationship to Existing OAuth Server
docs/internals/OAUTH-AUTHORIZATION-SERVER.md documents the existing SERV OAuth authorization server primitives: authorization code, client credentials, token exchange, stores, consent, JWT signing, and well-known metadata.
V1 uses that work for JWT/JWK primitives but does not require the full SERV authorization server. V2 uses SERV as the approval-server side of the deployed MCP story.
Missing after V1:
- hosted
PHOTON_MCP_JWKS_URLsupport and JWKS caching - browser consent and tenant-scoped approval UX
- token revocation/introspection
- client credentials after tenant pre-approval
- token exchange for downstream audience delegation
- generic multi-tenant binding helpers
V2 Open Questions
- Should hosted JWKS be fetched and cached by the Worker, or should deploy continue to embed a public JWKS snapshot?
- Should token revocation use introspection, a signed deny-list, or short TTLs plus key rotation?
- How should Photon represent tenant context generically without forcing every photon into one tenancy model?
- Which OAuth grants should be first-class for agents after V1: authorization code with consent, client credentials after pre-approval, token exchange, or all three?
- How should V2 migrate existing local-issuer deployments to hosted authorization servers?
