What "validating" actually means

When Claude sends an MCP request with Authorization: Bearer <jwt>, we have to be sure of three things before we trust it:

  1. The signature is real. It was actually minted by Supabase using its current signing key. We verify against the JWKS document at https://<ref>.supabase.co/auth/v1/.well-known/jwks.json — which is why step 4 migrated the project to asymmetric (ES256) signing keys; an HS256 shared secret never appears in a JWKS.
  2. The token is from our auth server, for our users. Standard iss and aud checks: issuer must be exactly https://<ref>.supabase.co/auth/v1, audience authenticated.
  3. The token isn't expired or otherwise junked. Standard exp checks.

And one thing we can't check, worth being honest about: Supabase's OAuth server doesn't support RFC 8707 resource indicators today, so tokens aren't bound to this specific MCP server. Any valid, unexpired token from your Supabase project — whatever app obtained it — will pass the checks above. That's a smaller deal than it sounds: RLS still scopes every query to the token's user, so a "wrong-app" token grants exactly what that user could already see. What the tokens do carry is a client_id claim naming the OAuth client that obtained them, and we'll expose an optional allowlist on it for anyone who wants the belt with the suspenders.

We use jose (a small, well-maintained JWT library that runs on Deno) to handle the cryptography.

1. Create the auth module

Make supabase/functions/mcp/auth.ts:

import { createRemoteJWKSet, jwtVerify } from "jose";
import type { Context, Next } from "hono";
 
const PROJECT_URL = Deno.env.get("PROJECT_URL")!;
const ISSUER = `${PROJECT_URL}/auth/v1`;
const SELF_URL = Deno.env.get("MCP_SELF_URL")!;
 
// Optional: comma-separated list of OAuth client_ids allowed to call this
// server. Empty (the default) accepts any client from this Supabase project.
const ALLOWED_CLIENT_IDS = (Deno.env.get("ALLOWED_CLIENT_IDS") ?? "")
  .split(",")
  .map((s) => s.trim())
  .filter(Boolean);
 
// JWKS is fetched once and cached. jose handles key rotation by re-fetching
// when it sees an unfamiliar `kid`.
const JWKS = createRemoteJWKSet(
  new URL(`${ISSUER}/.well-known/jwks.json`)
);
 
export type AuthedUser = {
  sub: string;           // Supabase user id (== auth.uid())
  email?: string;
  clientId?: string;     // The OAuth client_id (which MCP client connected)
  raw: string;           // The original JWT — we forward it to PostgREST
};
 
// Hono type env: makes `c.get("user")` typed wherever requireAuth runs.
export type AuthEnv = { Variables: { user: AuthedUser } };
 
export async function verifyBearer(token: string): Promise<AuthedUser> {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: ISSUER,
    audience: "authenticated",
  });
 
  const clientId = payload.client_id as string | undefined;
 
  // Supabase's OAuth server doesn't support RFC 8707 resource binding today,
  // so any valid token from this project passes the checks above. The
  // client_id claim records which OAuth client obtained the token; an
  // optional allowlist narrows who may call this server.
  if (
    ALLOWED_CLIENT_IDS.length > 0 &&
    !ALLOWED_CLIENT_IDS.includes(clientId ?? "")
  ) {
    throw new Error(`OAuth client "${clientId ?? "unknown"}" is not allowed`);
  }
 
  return {
    sub: payload.sub as string,
    email: payload.email as string | undefined,
    clientId,
    raw: token,
  };
}
 
/**
 * Hono middleware. Returns a 401 with WWW-Authenticate on failure;
 * on success, attaches `user` to the context.
 */
export async function requireAuth(c: Context<AuthEnv>, next: Next) {
  const header = c.req.header("Authorization");
  if (!header || !header.toLowerCase().startsWith("bearer ")) {
    return unauthorized(c, "missing bearer token");
  }
 
  const token = header.slice("bearer ".length).trim();
  try {
    const user = await verifyBearer(token);
    c.set("user", user);
    await next();
  } catch (err) {
    return unauthorized(c, (err as Error).message);
  }
}
 
function unauthorized(c: Context<AuthEnv>, reason: string) {
  return c.json(
    { error: "unauthorized", reason },
    401,
    {
      "WWW-Authenticate":
        `Bearer realm="${SELF_URL}", ` +
        `resource_metadata="${SELF_URL}/.well-known/oauth-protected-resource", ` +
        `error="invalid_token", ` +
        `error_description="${reason.replace(/"/g, "'")}"`,
    }
  );
}

A few notes:

  • createRemoteJWKSet caches the JWKS in memory and refetches when it sees a key id (kid) it doesn't recognize. That's exactly the behavior you want for key rotation — no manual cache invalidation.
  • jwtVerify does signature + iss + aud + exp checks in one pass. If anything fails it throws. After step 4's migration the tokens are ES256-signed and the JWKS carries the matching P-256 public key.
  • AuthEnv is a Hono typing convention: declaring { Variables: { user: AuthedUser } } once means c.get("user") is properly typed in every handler behind the middleware — no casts.
  • The allowlist is optional and off by default. For a team tool, "any client, but only your project's users, each seeing only their own rows" is a reasonable posture. If you later want only Claude clients calling this server, log the client_id values you see (step 11 wires up logging) and set ALLOWED_CLIENT_IDS to exactly those.

2. Wire the middleware into the MCP route

Open supabase/functions/mcp/index.ts. First import the middleware and its type env, and let the Hono constructor know about the user variable:

import { requireAuth, type AuthEnv } from "./auth.ts";
 
const app = new Hono<AuthEnv>().basePath("/mcp");

Then replace the temporary app.all("/", ...) with:

app.all("/", requireAuth, async (c) => {
  const user = c.get("user");
  return c.json({
    message: "you are authenticated",
    sub: user.sub,
    email: user.email,
    clientId: user.clientId,
  });
});

This is still not the real MCP RPC — the SDK gets wired in next step — but now you can prove auth works end-to-end with curl.

3. Smoke test with a real Supabase token

Get a token via the password grant (requires email/password auth enabled in step 4). The token comes from your cloud project; the function under test runs locally and verifies it against the cloud JWKS — exactly the split we set up in step 2:

TOKEN=$(curl -s -X POST \
  "https://<ref>.supabase.co/auth/v1/token?grant_type=password" \
  -H "apikey: <your-anon-key>" \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com","password":"your-password"}' \
  | jq -r .access_token)
 
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq

The decoded payload should show iss, sub, email, aud: "authenticated", exp. Now hit the MCP endpoint:

# Unauthenticated — expect 401 + WWW-Authenticate
curl -i http://127.0.0.1:54321/functions/v1/mcp
 
# Authenticated — expect 200 + your sub/email
curl -i \
  -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:54321/functions/v1/mcp
HTTP/1.1 200 OK
{"message":"you are authenticated","sub":"...","email":"..."}

4. Test the failure modes

Each should return 401 with a clear error_description:

# Missing token
curl -i http://127.0.0.1:54321/functions/v1/mcp
 
# Garbage token
curl -i -H "Authorization: Bearer not-a-jwt" \
     http://127.0.0.1:54321/functions/v1/mcp
 
# Expired token (wait an hour, or manually craft one with a past exp)
 
# Token signed by a different Supabase project
curl -i -H "Authorization: Bearer <token-from-other-project>" \
     http://127.0.0.1:54321/functions/v1/mcp

If any of those return 200, the verification is broken — debug auth.ts before continuing.

5. About the password-grant token (development only)

The password grant is convenient for testing because you can fetch a token via curl, but it's a Supabase Auth shortcut, not an OAuth 2.1 flow. The token Claude will obtain in production goes through the authorization code with PKCE flow:

Claude → opens browser → user signs in + approves on your consent page
       → Supabase redirects to Claude with authorization_code
       → Claude exchanges code+code_verifier for access_token

Both flows produce JWTs of the same shape, and our verification works the same for either. Step 11 walks through the real flow with Claude Code.

6. Common misconceptions

"A valid signature means the token was meant for my server." Not here. Supabase's OAuth server doesn't implement RFC 8707 resource binding yet, so a token your user's other apps obtained from the same Supabase project verifies just as cleanly. Understand what actually contains the blast radius: RLS pins every query to the token's user, and the optional ALLOWED_CLIENT_IDS check pins which OAuth clients you'll serve. If resource binding ships later, adding an audience check here is a two-line change.

"The service role key is simpler — let me just use that." The service role bypasses RLS and represents the entire project, not a user. Using it in an MCP server destroys multi-tenancy: there's no auth.uid(), every query sees everything. Resist.


Tokens are now properly validated. Step 7 takes that token and uses it to talk to Postgres — letting RLS, not the application code, decide who can see what.