What we're enabling

Supabase Auth ships with an OAuth 2.1 server — currently in beta, free on all plans during the beta period — that supports exactly the pieces the MCP authorization spec requires:

  • OAuth 2.1 with PKCE for the authorization code flow
  • RFC 8414 authorization server metadata at a well-known URI
  • RFC 7591 dynamic client registration — so Claude can self-register without you pre-creating a client
  • JWT access tokens that include a client_id claim and survive normal RLS
  • Refresh tokens with rotation

This is recent enough that most existing MCP+Supabase tutorials predate it and recommend hand-rolled auth instead. We're using the supported, on-spec path — with one catch worth stating up front: Supabase provides the protocol endpoints, but not the UI. There is no hosted sign-in-and-approve screen. You build a small consent page (one static HTML file, below) and tell Supabase where it lives. That's the price of the beta, and it's about thirty minutes of work.

1. Enable the OAuth server in the dashboard

Supabase project → AuthenticationOAuth Server.

Three settings to make:

  • Toggle the OAuth 2.1 server on. The base URL for the auth server is https://<ref>.supabase.co/auth/v1.
  • Turn on Dynamic Client Registration — this is what lets Claude register itself as an OAuth client on first contact.
  • Set the Authorization path to /oauth/consent. This is the path — relative to your project's Site URL — where Supabase will send users to approve or deny a connection. We'll build and host that page in a moment.

2. What about redirect URIs?

If you've configured OAuth providers before, you may be reaching for an "allowed redirect URIs" list with entries like http://127.0.0.1/*. Don't — wildcards aren't supported, and with dynamic client registration you don't need any of it. When Claude registers itself (RFC 7591), it declares its own redirect URIs — the ephemeral localhost callback Claude Code listens on — as part of the registration request, and Supabase holds each client to exactly the URIs it registered, per OAuth 2.1's strict matching rules. Nothing to configure on your side.

During the authorization flow, Supabase redirects the user's browser to Site URL + /oauth/consent?authorization_id=<id>. Your page's job: make sure the user is signed in, show them what's being requested, and call one of two supabase-js methods — approveAuthorization or denyAuthorization. Both return a redirect_url that sends the browser back to the client to finish the flow.

Here's a minimal, single-file version. Save it as consent.html (it can live anywhere your repo keeps static assets — it does not deploy with the Edge Function):

<!-- consent.html — hosted at <site-url>/oauth/consent -->
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Shared Skills — authorize access</title>
</head>
<body>
  <h1>Shared Skills</h1>
  <p id="status">Loading…</p>
 
  <form id="login" hidden>
    <input id="email" type="email" placeholder="email" required>
    <input id="password" type="password" placeholder="password" required>
    <button>Sign in</button>
  </form>
 
  <div id="consent" hidden>
    <p id="summary"></p>
    <button id="approve">Approve</button>
    <button id="deny">Deny</button>
  </div>
 
  <script type="module">
    import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
 
    const supabase = createClient(
      "https://<ref>.supabase.co",
      "<your-anon-public-key>"          // the anon key is safe in client code
    );
 
    const authorizationId =
      new URLSearchParams(location.search).get("authorization_id");
    const $ = (id) => document.getElementById(id);
 
    async function main() {
      if (!authorizationId) {
        $("status").textContent =
          "Missing authorization_id — this page is only reached via an OAuth flow.";
        return;
      }
 
      // 1. The approve/deny calls act on the CURRENT session, so the user
      //    must be signed in on this page.
      const { data: { session } } = await supabase.auth.getSession();
      if (!session) {
        $("status").textContent = "Sign in to continue.";
        $("login").hidden = false;
        $("login").onsubmit = async (e) => {
          e.preventDefault();
          const { error } = await supabase.auth.signInWithPassword({
            email: $("email").value,
            password: $("password").value,
          });
          if (error) { $("status").textContent = error.message; return; }
          location.reload();
        };
        return;
      }
 
      // 2. Show the user what's being requested.
      const { data, error } =
        await supabase.auth.oauth.getAuthorizationDetails(authorizationId);
      if (error) { $("status").textContent = error.message; return; }
 
      $("status").textContent = "";
      $("summary").textContent =
        `"${data.client?.name ?? "An MCP client"}" wants access to your ` +
        `snippet library as ${session.user.email}.`;
      $("consent").hidden = false;
 
      // 3. Approve or deny, then follow Supabase's redirect back to the client.
      $("approve").onclick = async () => {
        const { data, error } =
          await supabase.auth.oauth.approveAuthorization(authorizationId);
        if (error) { $("status").textContent = error.message; return; }
        location.href = data.redirect_url;
      };
      $("deny").onclick = async () => {
        const { data, error } =
          await supabase.auth.oauth.denyAuthorization(authorizationId);
        if (error) { $("status").textContent = error.message; return; }
        location.href = data.redirect_url;
      };
    }
 
    main();
  </script>
</body>
</html>

Unstyled, but complete: session check, sign-in fallback, client name display, approve/deny. Dress it up later — the shape is what matters, and it's the shape Supabase's docs prescribe: getAuthorizationDetails(authorization_id) to describe the request, approveAuthorization / denyAuthorization to resolve it.

4. Host it and set the Site URL

The page must be reachable at Site URL + Authorization path. Any static host works:

  • Vercel / Netlify / Cloudflare Pages — drop the file in as /oauth/consent/index.html (or configure a rewrite from /oauth/consent).
  • GitHub Pages — same trick with a directory index.
  • Supabase Storage — a public bucket can serve the HTML too, though the URL shape makes the rewrite awkward; a real static host is less fiddly.

Once it's live at, say, https://skills.example.com/oauth/consent, go to Authentication → URL Configuration and set Site URL to https://skills.example.com. Supabase now knows the full consent URL: Site URL (https://skills.example.com) + Authorization path (/oauth/consent).

5. Switch to asymmetric JWT signing keys

Fresh Supabase projects sign JWTs with HS256 — a shared secret. That's a problem for us: our Edge Function will verify tokens against the project's public JWKS (step 6), and a shared-secret key never appears in the JWKS. The fix is Supabase's own recommended migration to asymmetric keys:

  1. Dashboard → Project SettingsJWT Keys.
  2. Click Migrate JWT secret — this imports the legacy secret and creates a new asymmetric key as a standby.
  3. Click Rotate keys to make the asymmetric key the one that signs new tokens. Existing tokens stay valid until they expire; nobody gets logged out.

Supabase's default asymmetric algorithm is ES256 (NIST P-256) — faster than RSA at comparable security, with much smaller signatures.

6. Confirm the well-known endpoints

Hit them with curl — they're public:

curl https://<ref>.supabase.co/.well-known/oauth-authorization-server/auth/v1 | jq

You should see something like:

{
  "issuer": "https://<ref>.supabase.co/auth/v1",
  "authorization_endpoint": "https://<ref>.supabase.co/auth/v1/oauth/authorize",
  "token_endpoint": "https://<ref>.supabase.co/auth/v1/oauth/token",
  "registration_endpoint": "https://<ref>.supabase.co/auth/v1/oauth/register",
  "jwks_uri": "https://<ref>.supabase.co/auth/v1/.well-known/jwks.json",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"],
  "scopes_supported": ["openid", "email", "profile"]
}

Three things to notice:

  1. registration_endpoint — Claude will POST a small JSON document here to register itself.
  2. code_challenge_methods_supported: ["S256"] — PKCE is supported, MCP requires it.
  3. token_endpoint_auth_methods_supported: ["none", ...]none means public clients (no client secret) work, which is what MCP clients are.

If any of these are missing, double-check the dashboard toggles in section 1. Then fetch the JWKS:

curl https://<ref>.supabase.co/auth/v1/.well-known/jwks.json | jq

After the key migration in section 5, you should see an EC public key with "alg": "ES256" and "crv": "P-256". If the JWKS is empty or you only see the legacy setup, revisit section 5 — step 6's verification depends on this key being published. Copy the kid for sanity; you'll see it again in token headers.

7. Choose how users sign in

Supabase OAuth's job is "obtain an access token after the user authenticates somehow." The "somehow" is whatever Supabase Auth providers you have configured — and it's your consent page that hosts the sign-in. The minimal page above uses signInWithPassword, so enable email + password under Authentication → Sign-in Method.

Magic links or social providers (Google, GitHub, …) work too — swap the signInWithPassword call on the consent page for the corresponding supabase-js method. For a team blueprint, email + password or Google is friendliest; magic link is fine for solo testing but a hassle when you're logging in repeatedly during development.

8. Sanity check the issuer URL — it matters

The MCP server (our Edge Function) is going to validate tokens by checking the iss claim. Supabase issues tokens with iss = https://<ref>.supabase.co/auth/v1. Note: with a trailing path component, no trailing slash.

In step 6 we'll hard-code https://<ref>.supabase.co/auth/v1 as the expected issuer and reject anything else. Worth confirming now by signing in once and inspecting the token:

# Get a token via the password grant (requires Email auth enabled)
curl -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"}'

Take the access_token, paste it into jwt.io, and verify the iss claim matches what you expect. While you're there, check the header: after section 5's migration, alg should be ES256.


The auth server is alive, and it knows where its consent page lives. Step 5 builds the MCP server skeleton on top of Hono and implements the Protected Resource Metadata document Claude will use to find this auth server.