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:
- 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. - The token is for us. The
audclaim (or, for Supabase OAuth, theresourceit was issued for) matches our canonical server URL. Without this, a token your user obtained for some other MCP server they use could be replayed here. RFC 8707 + RFC 9068 spell this out as a security requirement. - The token isn't expired or otherwise junked. Standard
exp+isschecks.
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 } from "hono";
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const ISSUER = `${SUPABASE_URL}/auth/v1`;
const SELF_URL = Deno.env.get("MCP_SELF_URL")!;
// 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
};
export async function verifyBearer(token: string): Promise<AuthedUser> {
const { payload } = await jwtVerify(token, JWKS, {
issuer: ISSUER,
// Supabase OAuth tokens carry `aud: "authenticated"` plus a separate
// `resource` (or `aud_resource`) claim for the MCP-style audience.
// We accept the standard aud here and then check resource binding
// explicitly so the error message is more helpful.
audience: "authenticated",
});
// RFC 8707 audience binding: confirm this token was issued for THIS
// server, not some other resource the user has access to.
const resource =
(payload as Record<string, unknown>).resource as string | undefined ??
(payload as Record<string, unknown>).aud_resource as string | undefined;
if (resource && resource !== SELF_URL) {
throw new Error(
`token resource mismatch: got "${resource}", expected "${SELF_URL}"`
);
}
return {
sub: payload.sub as string,
email: payload.email as string | undefined,
clientId: payload.client_id as string | undefined,
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, next: () => Promise<void>) {
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, 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:
createRemoteJWKSetcaches 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.jwtVerifydoes signature +iss+aud+expchecks in one pass. If anything fails it throws.- The audience check is two layers:
jwtVerify(..., { audience: "authenticated" })covers the standard Supabase audience; the explicitresourcecheck enforces the MCP audience binding.
2. Wire the middleware into the MCP route
Open supabase/functions/mcp/index.ts and import the middleware:
import { requireAuth } from "./auth.ts";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):
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 | jqThe 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 → Supabase redirects to Claude
with authorization_code
→ Claude exchanges code+code_verifier for access_tokenBoth 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
"I'll just check jwt.verify and call it a day." That covers the signature and standard claims, but not the audience binding. A token a user obtained for some other MCP server is a valid Supabase JWT — jwt.verify will happily accept it. The explicit resource check is what stops that.
"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.
"My tokens have a aud_resource claim, the example uses resource." Supabase has been migrating the claim name; both should work. The check above handles both names. If you see neither, you might be using a project on an older Auth release — upgrade in the Supabase dashboard.
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.