Generate the token
The token is a shared secret, so make it long and random. Anything unguessable works; base64 of 32 random bytes is a good default:
openssl rand -base64 32
# e.g. 9Xk2P/l0aQz7... (44 chars) — copy this, you'll set it as a secret belowDon't reuse a password, and don't make it "team123". It's the only thing standing between the internet and your snippet library.
What "checking the token" should and shouldn't do
Two things are easy to get subtly wrong, and both matter:
- Compare in constant time. The obvious
provided === expectedreturns the moment two bytes differ. That timing difference leaks how long a matching prefix an attacker has found, turning an impossible guess into a byte-at-a-time search. We avoid it by hashing both sides to fixed-length digests and XOR-comparing every byte. - Don't advertise auth you don't have. The OAuth build answers a missing token with
401plus aWWW-Authenticate: Bearer ... resource_metadata="..."header, which tells an MCP client "go discover my OAuth server." We have no OAuth server. Sending that header sends Claude down a discovery path that dead-ends. A static-token server answers with a bare401and nothing else.
Write the middleware
Create supabase/functions/mcp/auth.ts:
import type { Context, Next } from "hono";
// The one shared secret. Everyone on the team sends this exact string as
// `Authorization: Bearer <token>` (or ?token=<token>). There is no per-user
// identity in this design: the token IS the trust boundary.
const TEAM_TOKEN = Deno.env.get("TEAM_TOKEN")!;
// SHA-256 the expected token ONCE at module load. Comparing fixed-length
// digests (below) means the compare loop never leaks the token's length or
// content through timing.
const EXPECTED = await sha256(TEAM_TOKEN);
async function sha256(s: string): Promise<Uint8Array> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(s),
);
return new Uint8Array(digest);
}
// Constant-time compare of two equal-length byte arrays. XOR-accumulate every
// byte so the time is identical regardless of where (or whether) they differ.
function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;
}
function extractToken(c: Context): string {
const header = c.req.header("Authorization") ?? "";
if (header.toLowerCase().startsWith("bearer ")) {
return header.slice("bearer ".length).trim();
}
// Fallback for clients that can't set a custom header (e.g. Claude Desktop's
// connector UI): allow ?token=... on the URL. Convenient, but query strings
// land in logs and history — prefer the header when the client supports it.
return new URL(c.req.url).searchParams.get("token") ?? "";
}
export async function requireTeamToken(c: Context, next: Next) {
const token = extractToken(c);
if (!token) {
return c.json({ error: "unauthorized", reason: "missing team token" }, 401);
}
const ok = timingSafeEqual(await sha256(token), EXPECTED);
if (!ok) {
return c.json({ error: "unauthorized", reason: "invalid team token" }, 401);
}
await next();
}Notes on the choices:
- Hashing before comparing does double duty: it forces both sides to the same 32-byte length (so
timingSafeEqual's length check never itself leaks anything about the real token), andcrypto.subtle.digestis built into the Deno/Edge runtime, so there's no dependency to add. - Header or
?token=. This is the exact "shared token appended to the MCP URL" convenience you might want for clients without a header field — supported, but documented as second-best because query strings show up in access logs, proxies, and browser history. Reach for the header first. - The middleware is the whole auth system. There is no user object, no
c.set("user", ...), nothing downstream to thread. Past this gate, every request is identical.
Set the token as a secret
Update your local supabase/functions/.env with the real value:
PROJECT_URL=https://<ref>.supabase.co
SERVICE_ROLE_KEY=<your-service-role-key>
TEAM_TOKEN=<the-token-you-generated>And set the production secrets now so a later deploy has them:
supabase secrets set \
PROJECT_URL=https://<ref>.supabase.co \
SERVICE_ROLE_KEY=<your-service-role-key> \
TEAM_TOKEN=<the-token-you-generated>Restart
supabase functions serveafter editing.env— it reads the file at startup, so a changedTEAM_TOKENwon't take effect until you do.
Step 5 wires this middleware in front of the real MCP endpoint and connects the service-role client from step 3.