Skip to content

Securing MCP with JWT

Photon can protect deployed MCP tool calls with short-lived JWT access tokens. This is the recommended V1 path when you want known agents to manage a photon without sharing a long-lived bearer secret.

This guide covers the local-issuer flow:

  • the deployer creates an ES256 signing key on their machine
  • Photon deploys only public verification material with the Worker
  • agents receive short-lived scoped JWTs
  • /mcp verifies the token before user code runs
  • method-level @scope can override the default per-tool scope

For user OAuth against third-party APIs, see OAuth Authentication. For MCP client registration with a hosted Photon authorization server, see Registering an MCP Client with a Photon AS.

When to Use This

Use MCP JWT auth when:

  • you deploy a single-tenant photon
  • one or more trusted agents need to call MCP tools
  • you want scoped, expiring credentials instead of one shared bearer string
  • you are not yet running a hosted OAuth approval server

For multi-tenant approval flows, use the Photon authorization server design instead of local issuer mode. Local issuer mode is deliberately small: it gives one deployer a secure way to authorize their own agents.

Quick Start

Create a keypair for the photon:

bash
photon auth init appointments

Deploy the photon with JWT auth enabled:

bash
photon host deploy cf appointments \
  --mcp-auth jwt \
  --mcp-audience https://appointments.example.com/mcp

Issue a short-lived token for an agent:

bash
photon auth token appointments \
  --agent scheduler \
  --audience https://appointments.example.com/mcp \
  --scope bookings:write \
  --ttl 15m

Verify a token locally:

bash
photon auth verify appointments "$TOKEN" \
  --audience https://appointments.example.com/mcp

The agent calls MCP with:

http
Authorization: Bearer <jwt>

Tool Scopes

Photon assigns every MCP tool a default scope when JWT auth is active:

  • @readOnly methods require &lt;toolName&gt;:read
  • all other methods require &lt;toolName&gt;:write

Use method-level @scope only when the default tool-name scope is not the permission vocabulary you want to expose.

typescript
export default class Appointments {
  /**
   * Book a paid consultation slot.
   * @scope bookings:write
   */
  async book({ slotId }: { slotId: string }) {
    return {
      booked: true,
      slotId,
      bookedBy: this.caller.id,
    };
  }

  /**
   * Read available consultation slots.
   * @scope bookings:read
   */
  async slots() {
    return [{ id: 'slot_1', startsAt: '2026-06-01T09:00:00Z' }];
  }
}

Scope rules:

  • @scope a b requires both a and b
  • repeated @scope tags are additive
  • @scope is method-level only
  • no @scope means Photon uses &lt;toolName&gt;:read or &lt;toolName&gt;:write
  • @internal and @audience user are visibility hints, not authorization

What Gets Stored Where

photon auth init appointments creates files under:

text
~/.photon/auth/appointments/
  private.jwk
  public.jwk
  jwks.json
  issuer.json

private.jwk stays on the deployer's machine and is used only by photon auth token. Do not commit or deploy it.

jwks.json contains public verification keys. When you deploy with --mcp-auth jwt, Photon embeds this public verification material in the generated Worker so the Worker can verify signatures.

Audience Must Match

The deploy audience and token audience must be the same value:

bash
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:write

If the aud claim does not match, the Worker rejects the token.

Runtime Behavior

For MCP methods that dispatch user code:

ConditionResponse
missing token401 Unauthorized
invalid signature, issuer, audience, or expiry401 Unauthorized
valid token missing a required scope403 Forbidden
valid token with required scopestool executes

Discovery methods such as initialize, tools/list, and ping remain callable so clients can discover the server before they have a token.

Inside tool code, this.caller is populated from the JWT:

typescript
{
  id: 'agent:scheduler',
  name: undefined,
  anonymous: false,
  scope: 'bookings:write',
  claims: { ... }
}

Local Beam and SSE Testing

The same JWT mode can be tested locally. The easiest path points the local server at an existing auth profile on disk:

bash
export PHOTON_MCP_AUTH_MODE=jwt
export PHOTON_MCP_JWT_PROFILE=appointments
export PHOTON_MCP_JWT_AUDIENCE=http://127.0.0.1:3000/mcp

photon mcp appointments --transport sse --port 3000

PHOTON_MCP_JWT_PROFILE reads ~/.photon/auth/&lt;name&gt;/issuer.json and jwks.json directly, so the issuer string and JWKS are picked up from disk. The profile is cached per process, so the keys are read once at startup.

Inline JWKS still works for ephemeral test setups:

bash
export PHOTON_MCP_AUTH_MODE=jwt
export PHOTON_MCP_JWT_ISSUER=photon-local:appointments
export PHOTON_MCP_JWT_AUDIENCE=http://127.0.0.1:3000/mcp
export PHOTON_MCP_JWT_JWKS='{"keys":[...]}'

Then call http://127.0.0.1:3000/mcp with Authorization: Bearer &lt;jwt&gt;.

Audience Claim

claims.aud is matched against the configured audience. Both forms of the aud claim defined in RFC 7519 are accepted: a single string, or an array of strings (the configured audience must appear in the array). Photon's own issuer always emits a single-string aud, so this matters only when verifying tokens minted by other issuers.

Trying Tools from the Bundled Playground

The deploy-time playground at / POSTs tools/call to /mcp. When JWT (or bearer) auth is active, the playground includes an Authorization: Bearer &lt;token&gt; header on each call. Use the Set token link above the tool form to paste a JWT issued by photon auth token (the token is kept in this browser's localStorage only, key photon_mcp_token). Clear token wipes it. A 401 or 403 response shows a hint to set or refresh the token.

Key rotation

The Worker does not fetch JWKS at runtime. Photon bakes the contents of ~/.photon/auth/&lt;name&gt;/jwks.json into the generated Worker bundle at deploy time by substituting __MCP_JWT_JWKS__ in the Worker template. There is no remote JWKS endpoint and no in-Worker key fetch, so any key change only takes effect after a fresh photon host deploy cf.

jwks.json is a JWKS array, and every token carries a kid in its JWT header that the Worker matches against the entries in that array. Multi-key JWKS is therefore safe: tokens signed by the old key keep verifying as long as the old public key is still in the array.

To rotate without breaking in-flight tokens:

  1. Run photon auth init &lt;name&gt; --rotate to generate a new keypair.
  2. Edit ~/.photon/auth/&lt;name&gt;/jwks.json so it lists BOTH the old and the new public keys.
  3. Redeploy with --mcp-auth jwt. The Worker now accepts tokens signed by either key, matched by kid.
  4. Wait until every outstanding token has expired (default TTL is 15 minutes; wait at least one full TTL window).
  5. Trim jwks.json back to just the new public key and redeploy again. The old key is now fully retired.

Skip steps 2 to 4 only if you are willing to invalidate every live token at the moment of redeploy.

Compatibility with Bearer Auth

PHOTON_MCP_BEARER still works for existing deployments when JWT mode is not enabled. Once you deploy with --mcp-auth jwt, MCP tool calls must use JWTs; the deploy command warns about this so existing bearer clients can migrate.

Security Notes

  • Keep private.jwk local and out of source control.
  • Use short TTLs for agent tokens.
  • Grant only the scopes each agent needs.
  • Treat the audience as part of the resource identity; do not reuse tokens across different deployments.
  • Public web routes declared with @get, @post, or @expose public are not protected by MCP JWT auth. Route-level HTTP auth remains the photon's responsibility.

Released under the MIT License.