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 below

Don'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:

  1. Compare in constant time. The obvious provided === expected returns 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.
  2. Don't advertise auth you don't have. The OAuth build answers a missing token with 401 plus a WWW-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 bare 401 and 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), and crypto.subtle.digest is 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 serve after editing .env — it reads the file at startup, so a changed TEAM_TOKEN won'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.