BlogEngineering
Remote MCP server OAuth: what we learned shipping it
Notes from a production MCP server that uses Supabase Auth as its authorization server: what the spec asks for, what we got wrong, and what we still haven’t tested.
The short answer
A remote MCP server is an OAuth protected resource. Without a token, it answers 401 and points to its RFC 9728 metadata; the client follows that to the authorization server, registers, signs the person in with PKCE and the resource parameter, and returns with a bearer token issued for the server’s URL. We run this in production with Supabase Auth. Three details took the work: a 401 with a scope hint and no error code, a token hook that adds our audience and scope to Supabase’s tokens, and dynamic client registration, since our authorization server doesn’t offer client ID metadata documents.
Who does what when an MCP server uses OAuth?
The MCP server has the smallest job: it accepts or rejects bearer tokens. The MCP authorization specification (version 2025-11-25) makes it an OAuth 2.1 resource server and the assistant an OAuth client, and lets the authorization server, which signs the person in and issues tokens, be a separate service.
Ours is separate. Supabase Auth’s OAuth 2.1 server issues every token, our website supplies the sign-in and consent page, and the endpoint at https://mcp.scorestarling.com/mcp only verifies tokens. It runs Streamable HTTP without MCP sessions and checks the token on every request. ScoreStarling turns recordings into editable sheet music, and this endpoint is how assistants reach it; the setup guide shows the steps people follow.
What happens when an MCP client connects?
The client starts with only the URL. Two answers from our server, shown as of October 3, 2026, tell it where to sign in; six more steps get it a token. First, a request without a token gets a 401 whose WWW-Authenticate header names the metadata and the scopes to request:
$ curl -si -X POST https://mcp.scorestarling.com/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' -d '{}'
HTTP/2 401
content-type: application/json
www-authenticate: Bearer resource_metadata="https://mcp.scorestarling.com/.well-known/oauth-protected-resource/mcp", scope="openid email profile"
{"error_description": "Authentication required"}
Second, the client fetches that metadata. RFC 9728 forms its address by inserting /.well-known/oauth-protected-resource between host and path, and requires resource to be identical to the URL the client is using, or the client must stop. Our Supabase project reference is replaced here:
{
"resource": "https://mcp.scorestarling.com/mcp",
"authorization_servers": ["https://<project-ref>.supabase.co/auth/v1"],
"scopes_supported": ["openid", "email", "profile"],
"bearer_methods_supported": ["header"]
}
The other six steps:
The client reads the authorization server’s metadata. Supabase’s issuer has a path, so RFC 8414 puts the well-known segment before it:
https://<project-ref>.supabase.co/.well-known/oauth-authorization-server/auth/v1. The client checks thatissuermatches exactly and thatcode_challenge_methods_supportedlistsS256; the MCP spec says to stop if that field is missing.It posts its own metadata to the advertised
registration_endpoint(dynamic client registration, RFC 7591) and gets aclient_id. Our test client registers as a public client with no secret; Anthropic’s documentation says Claude does too.It opens the browser at the authorization endpoint with a PKCE S256 challenge, the scope from the 401 and
resource=https://mcp.scorestarling.com/mcp(RFC 8707). Supabase hands the browser to our consent page, which signs the person in if needed and asks them to approve or deny the client.It exchanges the code with its PKCE verifier and the same
resource, and gets an access token that lasts an hour plus a refresh token.It sends
Authorization: Bearer …on every MCP request. We check the signature against the issuer’s published keys, the issuer, audience, expiry and write scope, then that the account has not been turned off.When the token expires, or we answer 401 with
invalid_token, it refreshes, again withresource, and gets a new access token and refresh token.
Clients must send resource on the authorization and token requests even if the authorization server ignores it. Supabase’s OAuth flow documentation doesn’t mention the parameter (checked October 3, 2026). Supabase accepted it in all our live runs, and the audience in our tokens comes from a hook.
What should the 401 WWW-Authenticate header contain?
When no token was sent: where the metadata is, which scopes to ask for, and no error code. RFC 6750 §3.1 says a request without any authentication information should not get an error code. invalid_token is for a token that was sent and failed, and tells the client it may get a new one and retry. The MCP spec adds that the 401 should carry a scope hint, which clients must treat as the scopes to request.
We found our own server getting this wrong while writing our setup guide. In MCP Python SDK 1.30.0, RequireAuthMiddleware answers every unauthenticated request with error="invalid_token" and no scope, so a client that had never sent a token was told its token was invalid. A small ASGI middleware around the MCP route now rewrites only the SDK’s 401s; the SDK still decides who gets in, and its 403 insufficient_scope answer is untouched:
# No token, before the fix (the SDK's default; wrapped for reading)
WWW-Authenticate: Bearer error="invalid_token",
error_description="Authentication required",
resource_metadata="…/.well-known/oauth-protected-resource/mcp"
# No token, now
WWW-Authenticate: Bearer
resource_metadata="…/.well-known/oauth-protected-resource/mcp",
scope="openid email profile"
# Rejected token: expired, wrong audience, malformed or account turned off
WWW-Authenticate: Bearer error="invalid_token",
error_description="Invalid or expired access token",
resource_metadata="…/.well-known/oauth-protected-resource/mcp",
scope="openid email profile"
The hint names openid email profile, the scopes Supabase can grant, matching scopes_supported. Our deployment check now fails if a no-token challenge carries an error code. The fix reached production on October 2, 2026, within four minutes of merging. Anthropic’s connector docs add two rules for Claude: sign-in starts only from a 401 (it ignores WWW-Authenticate on a 200), and it uses only the first entry in authorization_servers.
How do you get an MCP audience and scope into Supabase tokens?
With a custom access token hook. The MCP spec says a server must accept only tokens issued for it, which in practice means checking aud, and we wanted a write scope of our own; Supabase’s documentation says its OAuth server doesn’t support custom scopes (checked October 3, 2026). The hook is a Postgres function that Supabase Auth runs before issuing a token, and it can rewrite claims. Ours, slightly simplified from our migration:
create or replace function scorestarling.access_token_hook(event jsonb)
returns jsonb language plpgsql stable security invoker set search_path = '' as $$
declare claims jsonb := event->'claims';
begin
-- Tokens issued to an OAuth client carry its client_id; website sessions don't.
if coalesce(claims->>'client_id', '') <> '' then
claims := jsonb_set(claims, '{aud}', '"https://mcp.scorestarling.com/mcp"');
claims := jsonb_set(claims, '{scope}', '"openid email profile scorestarling:write"');
end if;
return jsonb_build_object('claims', claims);
end;
$$;
grant usage on schema scorestarling to supabase_auth_admin;
grant execute on function scorestarling.access_token_hook(jsonb) to supabase_auth_admin;
revoke all on function scorestarling.access_token_hook(jsonb) from public, anon, authenticated;
The MCP side checks those claims with PyJWT, then applies two rules of its own:
claims = jwt.decode(token, key, algorithms=["RS256", "ES256"],
issuer=ISSUER, audience=ACCEPTED_AUDIENCES,
options={"require": ["exp", "iat", "iss", "aud", "sub"]})
if "scorestarling:write" not in claims.get("scope", "").split():
return None # the client gets 401 invalid_token
# then: has this account been turned off?
ACCEPTED_AUDIENCES is the MCP URL, plus the old one during a move. The client-ID test keeps the website’s own sign-in out: a browser session token from the same Supabase project has the same issuer and keys but no client_id, so it never gets the MCP audience.
The hook has two costs. It stamps one fixed audience rather than following the client’s resource, which works while one authorization server serves one MCP server. And our consent page can only show the scopes the client asked for, so approving the client is what grants the write scope.
An open report, supabase/auth#2820 (opened September 20, 2026; no replies by October 3), says Supabase’s authorization-details request returns 400 for public clients, offline_access or resource. None of these reproduced for us; in the Supabase Auth source we reviewed, that 400 appears only for an authorization that is no longer pending, such as one fetched twice.
Dynamic client registration or client ID metadata documents?
Use what your authorization server advertises; with Supabase today, that is dynamic client registration (DCR). The 2025-11-25 MCP spec recommends client ID metadata documents (CIMD) when client and server have never met, and keeps DCR for backwards compatibility. Clients should try a client ID given in advance first, then CIMD, then DCR, and only then ask the person.
| Approach | How the client identifies itself | Signal in the server’s metadata | Option in Claude’s form | Our server, October 3, 2026 |
|---|---|---|---|---|
| Pre-registration | A client ID created in advance, entered by hand or built in | None needed | Use your own OAuth client | Not offered |
| Client ID metadata document | Its client_id is an HTTPS URL to a JSON file the client hosts, which the server fetches | client_id_metadata_document_supported | Use Claude’s published identity (recommended) | Not advertised |
| Dynamic client registration | It posts its metadata to the registration endpoint and gets a client_id | registration_endpoint | Register automatically | Advertised |
Supabase’s metadata, read again on October 3, 2026, lists a registration endpoint, S256 and the none token-endpoint method, and no CIMD flag; its MCP guide has you switch dynamic registration on in the dashboard.
That decides what people pick in Claude’s custom-connector form. Anthropic’s documentation describes the recommended option, Claude’s published identity, as a CIMD that Anthropic hosts and the server must support. So our guide asks for Sign in now, since none of our tools works without an account, and Register automatically. Anthropic’s developer docs say Claude falls back to DCR when CIMD isn’t advertised (CIMD also needs none in token_endpoint_auth_methods_supported); we haven’t tested the form with its default left in place, so we ask for the explicit choice. The client docs we read on October 2, 2026 describe DCR for ChatGPT developer mode, Cursor, VS Code and Gemini CLI.
DCR has a cost: Anthropic notes that Claude registers a new client on every fresh connection, and Supabase warns that dynamic registration lets any MCP client register with your project. Registering grants nothing on its own, though. A person still has to sign in and approve the client, and the verifier checks the account on every request. Because any client can register under any name, our consent page has shown since October 6, 2026 where approving sends the person, and it warns when an app calls itself ChatGPT or Claude but doesn’t return to that assistant’s address.
Do MCP clients stay signed in after the access token expires?
Yes. Access tokens last an hour, and clients renew them with refresh tokens. Before relying on that, we read Supabase Auth’s source (master as of September 22, 2026):
- every code exchange returns a refresh token, with or without
offline_access; - a refresh must come from the session’s own client, rotates the refresh token and keeps the
client_idclaim; - the hook runs on refreshes too (
authentication_methodistoken_refresh), so our audience and scope come back each time.
A live run on October 2, 2026 matched: the refresh token rotated, and the renewed access token worked. Anthropic’s docs say Claude refreshes after a 401 and up to five minutes before expiry, and adds offline_access when the authorization server lists it in scopes_supported. Supabase’s metadata lists it, and our check that also requested offline_access passed.
Sessions still end when:
- the account is turned off, or the user is deleted or banned (in our live run, turning the account off rejected the browser session and both MCP tokens);
- an old refresh token is reused after Supabase’s reuse interval, 10 seconds by default, which revokes the whole session;
- a time-box, an inactivity timeout or “single session per user” is turned on. Keep the last one off, or a website sign-in ends the assistant’s session;
- the person signs out of the website with Supabase’s default
logout, which ends every session of the account, the assistant’s included. Ours did until October 6, 2026, the likely reason one ChatGPT connection could no longer renew its token; Sign out now sendsscope=localand ends only that browser’s session.
Supabase advertises no revocation endpoint (checked October 3, 2026), so turning the account off is how we cut off access.
Why does a trailing slash in the issuer matter?
Because issuers are compared character for character. RFC 8414 requires the issuer in the metadata to be identical to the one the client used to find it, and RFC 9728 asks the same of resource. To a strict client, https://auth.example.com and https://auth.example.com/ are different issuers.
The MCP Python SDK stores the issuer as a pydantic AnyHttpUrl, and pydantic 2.13.5, the version in our lockfile, turns https://auth.example.com into https://auth.example.com/. Metadata built from that names an issuer the authorization server never published, so our server writes its own protected resource metadata with the issuer exactly as configured. Supabase’s issuer ends in a path, which pydantic leaves alone, so this never hit us in production; it would with a bare-origin issuer. Our deployment check fails unless the authorization server’s issuer matches ours exactly.
What we’ve verified, and what we haven’t
Our first evidence came from a scripted client that follows the MCP spec. On October 2, 2026, one run against production covered the no-token 401, dynamic registration of a public client, consent and code exchange with resource, a refresh with rotation, tools/list with both tokens, and turning the account off, which rejected both tokens and the browser session. A second run also requested offline_access, and both passed. That run used our earlier address; the move to mcp.scorestarling.com has its own article.
Real clients have connected since. ChatGPT’s developer-mode app signed in through mcp.scorestarling.com and called a tool on October 3, 2026, and Claude connected and ran tools on October 6. Not verified yet:
- a refresh after a token has really expired: the scripted refresh ran right after sign-in, and we haven’t traced one from a real client;
- Supabase’s hosted session settings, which we haven’t read back.
Sources
- Authorization, MCP specification version 2025-11-25 — Model Context Protocol
- RFC 9728: OAuth 2.0 Protected Resource Metadata — IETF
- RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage, §3.1 — IETF
- RFC 8707: Resource Indicators for OAuth 2.0 — IETF
- RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol — IETF
- RFC 8414: OAuth 2.0 Authorization Server Metadata — IETF
- OAuth Client ID Metadata Document (Internet-Draft) — IETF OAuth Working Group
- Authentication for connectors — Anthropic
- Add a connector that isn’t in the directory — Anthropic
- OAuth 2.1 Flows — Supabase
- Custom Access Token Hook — Supabase
- Model Context Protocol (MCP) Authentication — Supabase
- User sessions — Supabase
- supabase/auth issue #2820 — GitHub
Questions and answers
Does a remote MCP server need its own authorization server?
No. The MCP authorization spec lets the authorization server be a separate service. The MCP server publishes RFC 9728 metadata that names it, then validates the tokens it issues, including their audience. Ours points to Supabase Auth and only verifies tokens.
Should an MCP server’s 401 include the invalid_token error when no token was sent?
No. RFC 6750 §3.1 says a request without credentials should get no error code. Send resource_metadata and a scope hint instead, and keep invalid_token for a token that was sent and rejected, where it tells the client to refresh.
Can Supabase Auth issue custom scopes for an MCP server?
Not as of October 3, 2026: Supabase’s OAuth 2.1 documentation says custom scopes aren’t supported. We request openid email profile and add our own scope and the MCP audience in a custom access token hook, which also runs on every refresh.
What should people choose in Claude’s custom connector form if the server doesn’t support CIMD?
Register automatically under OAuth client, which uses dynamic client registration, and Sign in now under Authentication if every tool needs an account. Claude’s recommended published identity is a client ID metadata document, and the server must support it. Our Claude steps show both choices.
Do I need the offline_access scope to get refresh tokens from Supabase?
Not in our tests. Supabase returned a refresh token on every code exchange without it, in its source and in live runs on October 2, 2026. Some clients ask for it anyway; Anthropic’s docs say Claude does when the authorization server lists it, and our check with it passed too.