Build an MCP Server with OAuth Authentication for Your SaaS

Build a remote MCP server your users connect AI agents to, authenticated with OAuth 2.1 and PKCE via Supabase. Covers consent, per-workspace scoping, token validation and RLS.

You can let your customers point Claude at your SaaS, have it read and write their data, and never issue an API key. To do that you build an MCP server that speaks OAuth 2.1, running remotely rather than on the user's machine, with Supabase Auth acting as the authorization server behind it.

What is a remote MCP server? A remote MCP server is an HTTP endpoint that exposes your application's data and actions to AI agents, acting as an OAuth 2.1 resource server. Agents authenticate as one of your existing users, request an access token through a consent flow you control, and call tools over the Model Context Protocol. Use one when you want AI agents to work on customer data without distributing credentials.

Most MCP tutorials build a local server: a process on the developer's machine, launched by the client, authenticated with an API key pasted into a config file. That is fine for personal tooling. It fails for a SaaS, because there is no per-user attribution, no consent, no revocation, and the key outlives the employee who created it.

This guide builds the other kind. The part that catches people out is tenancy: the access token carries no notion of which workspace the agent may touch, so something you own has to decide it. Everything below was verified against a running implementation on Next.js 16 and Supabase CLI 2.113.0 in August 2026, including seven places where the behaviour does not match the documentation.

Who plays which role

The single most common misunderstanding is expecting Supabase to give you an MCP server. It does not. Supabase gives you the authorization half, and you build the resource half.

RoleWho
Authorization serverSupabase Auth (/auth/v1/oauth/*)
Consent screenYou, a page in your app
Resource server (the MCP endpoint)You, a route handler
OAuth clientThe AI agent (Claude, Cursor, ChatGPT)

Supabase states this plainly in its own docs: it does not provide MCP server functionality. The OAuth 2.1 server is the piece that lets agents authenticate as your existing users.

OAuth flow to build an MCP server: an AI agent is refused with a 401, reads protected resource metadata, authorizes through your consent screen against Supabase Auth, and retries the tool call with a bearer tokenClick to expand

Read as a sequence: the agent calls your MCP endpoint with no token and gets a 401 carrying a resource_metadata pointer. It fetches that metadata, learns Supabase Auth is the authorization server, and sends the user through your consent screen, where they choose which workspace the agent may act on. Supabase issues the token, the agent retries, and Row Level Security decides what it can actually see.

What you need to build an MCP server

  • A Supabase project with the OAuth 2.1 server available
  • A Next.js App Router application, ideally multi-tenant
  • Supabase CLI with support for the [auth.oauth_server] config block, tested on 2.113
  • @supabase/auth-js with the auth.oauth namespace, tested on 2.112.3

Step 1: Turn Supabase into an OAuth authorization server

Enabling the OAuth server takes one config block and a restart. Add this to supabase/config.toml:

[auth.oauth_server]
enabled = true
authorization_url_path = "/oauth/consent"
allow_dynamic_registration = false

Then run supabase stop && supabase start. The authorization_url_path is appended to your Site URL to build the consent screen URL Supabase redirects users to. That page lives in your app, not in Supabase.

Migrate to asymmetric JWT signing (RS256 or ES256) before going further. The documented reason is that third parties can verify tokens using your public JWKS without you sharing a secret. The reason that matters more for an MCP server: on ES256, getClaims() verifies the signature in-process, which we confirmed on our own project. Symmetric HS256 keys cannot be published, so verification has to go back to the auth server. For an endpoint taking one token per tool call, that is the difference between a local check and a network hop. We did not benchmark an HS256 project, so treat the size of the gap as unmeasured.

Verify the server is live:

curl -s http://127.0.0.1:54321/auth/v1/.well-known/oauth-authorization-server

Step 2: Register a client, and watch the field name

Register OAuth clients through the admin API, using client_name rather than name. The Supabase getting-started guide shows name, which the API silently ignores. A client registered that way comes back with no name, and your consent screen renders "Authorize" followed by a blank.

curl -X POST 'http://127.0.0.1:54321/auth/v1/admin/oauth/clients' \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d '{
"client_name": "Acme AI Assistant",
"client_uri": "https://example.com",
"redirect_uris": ["https://yourapp.com/auth/callback"],
"client_type": "public",
"token_endpoint_auth_method": "none"
}'

Two more things that cost time. Send the secret key in the apikey header: a new-style sb_secret_... key sent only as Authorization: Bearer returns 401 no_authorization, because GoTrue reads new-style keys from apikey. The legacy service role JWT works in either header, and setting both, as above, works with either key format. And redirect URIs are exact matches with no wildcard support, unlike the general redirect allow-list.

Your consent screen is where the user decides what the agent may reach, and in a multi-tenant app that means more than a yes or no. Supabase redirects to it with an authorization_id, which you exchange for the request details.

const { data: details, error } =
await client.auth.oauth.getAuthorizationDetails(authorizationId);
// Two response shapes. When the user has already approved this client,
// Supabase returns a redirect instead of an authorization to consent to.
if (!('authorization_id' in details)) {
redirect(details.redirect_url);
}

That branch is not an edge case. It is the normal path for every returning user, and it has a consequence covered in the gotchas below.

The user then approves or denies, and you hand the decision back:

const { data } = await client.auth.oauth.approveAuthorization(authorizationId, {
skipBrowserRedirect: true,
});
return NextResponse.redirect(data.redirect_url, 303);

Use a 303 rather than the default 307. The form posts, and a 307 would replay the POST against the OAuth client's callback.

One routing detail specific to localized apps: the generic auth gate in Makerkit builds its sign-in redirect from the pathname alone, which drops the query string. Send an unauthenticated user through sign-in and they land back on a consent screen with no authorization_id and nothing to consent to. The consent route needs its own middleware entry that carries search through the round trip.

Step 4: Serve protected resource metadata

MCP servers must implement RFC 9728 Protected Resource Metadata, and the URL is built by path insertion rather than by appending a query. For a resource at https://app.com/api/mcp/acme, the metadata lives at https://app.com/.well-known/oauth-protected-resource/api/mcp/acme.

That shape calls for an optional catch-all route at app/.well-known/oauth-protected-resource/[[...path]]/route.ts:

export async function GET(
_request: Request,
context: { params: Promise<{ path?: string[] }> },
) {
const { path = [] } = await context.params;
const account = matchMcpResource(path);
if (account === undefined) {
return new Response('Not Found', { status: 404 });
}
return Response.json({
resource: getMcpResourceUri(account),
authorization_servers: [getAuthorizationServerIssuer()],
scopes_supported: ['openid', 'email', 'profile'],
bearer_methods_supported: ['header'],
});
}

Return 404 for anything that is not one of your resources, otherwise the endpoint will advertise resources that do not exist. Leave offline_access out of scopes_supported even though Supabase advertises it, because the MCP specification tells resource servers not to list it.

If your app is localized, exclude .well-known from your middleware matcher. The route sits outside the [locale] segment, and next-intl resolves the path into the localized tree, so the handler is never reached. The symptom is a flat 404 rather than the locale redirect you might expect.

'/((?!_next/static|_next/image|images|api/*|\\.well-known/*).*)'

Step 5: Validate the token, which is where this gets interesting

Token validation is the heart of an MCP resource server, and the MCP specification asks for something Supabase cannot currently express. The spec says a resource server must only accept tokens issued specifically for it, using RFC 8707 resource indicators for audience binding.

Supabase ignores the resource parameter. It accepts it without error and mints the same token regardless, and every token carries aud: "authenticated". That value is shared by every token the project issues, so audience alone proves nothing.

Here is a real decoded access token from the flow:

{
"iss": "http://127.0.0.1:54321/auth/v1",
"sub": "31a03e74-1639-45b6-bfa7-77447f1a4762",
"aud": "authenticated",
"aal": "aal1",
"amr": [{ "method": "oauth_provider/authorization_code" }],
"client_id": "4805c0a6-8eb7-417f-a9cc-f8320279c7fe",
"scope": "openid email profile"
}

The client_id claim is the useful one. Only the OAuth server mints it, and it survives token refresh. Three checks stand in for the audience binding the spec wants:

  1. Verify the signature and expiry against the project's JWKS
  2. Pin the issuer, because a token from a different Supabase project verifies fine against that project's keys
  3. Require client_id, which rejects first-party session tokens

That third check is the one people miss, and it is the confused deputy defence. A plain user session JWT is signed by the same project, has a valid issuer and subject, and is not expired. Every check the specification names for token validation passes. It is rejected only because it carries no client_id. Without that check, anything holding a user's cookie session could drive your MCP endpoint with no consent and no grant.

const client = getSupabaseBearerClient(token);
const { data, error } = await client.auth.getClaims(token);
if (error ?? !data?.claims) {
return unauthorized({ account, description: 'Invalid or expired token.' });
}
if (data.claims.iss !== getAuthorizationServerIssuer()) {
return unauthorized({ account, description: 'Wrong issuer.' });
}
const clientId = data.claims.client_id;
if (typeof clientId !== 'string' || !clientId) {
return unauthorized({ account, description: 'Not an OAuth client token.' });
}

Failures must carry a WWW-Authenticate challenge, or a 401 is a dead end and the client cannot discover where to authorize:

WWW-Authenticate: Bearer resource_metadata="https://app.com/.well-known/oauth-protected-resource/api/mcp/acme", scope="openid email profile"

Step 6: Solve multi-tenancy, because scopes will not

OAuth scopes cannot express which workspace an agent may act on, so tenancy needs its own model. Supabase supports four user-facing scopes, openid, email, profile and phone, plus offline_access for refresh tokens. Its documentation is explicit that these control ID token claims rather than table access, and custom scopes are not supported.

That leaves a gap. OAuth authenticates a user, but SaaS data belongs to an account, and a user typically belongs to several. If you have not settled that model yet, start with multi-tenant SaaS architecture.

Three options exist, and two of them are traps:

  • A tool argument (account_slug on every tool). Zero infrastructure, but the token's blast radius becomes every account the user belongs to. You end up with less scoping than an API key while claiming OAuth is safer.
  • A per-account URL alone (/api/mcp/acme). The right shape, but unenforceable by itself: since Supabase ignores resource, the token minted for one workspace is byte-identical to the token for another. Replaying it works.
  • A per-account URL enforced by your own grant table. The URL identifies the tenant, and a table you own decides whether that pairing is allowed.

Take the third. Store the workspace selection at consent time:

create table if not exists public.mcp_client_grants (
id uuid primary key default extensions.uuid_generate_v4(),
user_id uuid not null references auth.users (id) on delete cascade,
client_id uuid not null,
account_id uuid not null references public.accounts (id) on delete cascade,
created_at timestamptz not null default now(),
unique (user_id, client_id, account_id)
);

The policies matter as much as the table. Every one of them requires client_id to be absent from the JWT, so an agent holding a valid token cannot read which workspaces it was granted, cannot widen its own grant, and cannot revoke another client's:

create policy "mcp_client_grants_insert" on public.mcp_client_grants
for insert to authenticated
with check (
user_id = (select auth.uid())
and (auth.jwt() ->> 'client_id') is null
and (
account_id = (select auth.uid())
or public.has_role_on_account(account_id)
)
);

The last clause is the one that stops a tampered consent form inserting a grant for an arbitrary account. Grant no UPDATE to anyone, so a grant can be created or revoked but never re-parented.

Because the policies deliberately hide this table from agents, your server reads it with an admin client, which means the filters are load-bearing:

const { data } = await admin
.from('mcp_client_grants')
.select('id')
.eq('user_id', userId)
.eq('client_id', clientId)
.eq('account_id', accountId)
.maybeSingle();

One naming detail: personal accounts have no slug, enforced by a database constraint, so the URL needs a reserved segment. We use /api/mcp/me.

Step 7: Wire the transport and write tools

The MCP SDK ships a Web Standards transport, so the Next.js route handler is thin. @modelcontextprotocol/sdk 1.30 exports WebStandardStreamableHTTPServerTransport, whose handleRequest(Request) returns a Response. There is no Node request and response shim to write, which is the friction that makes third-party adapters tempting.

async function handle(
request: Request,
context: { params: Promise<{ account: string }> },
) {
const { account } = await context.params;
const auth = await authenticateMcpRequest(request, account);
if (!auth.ok) {
return auth.response;
}
const server = createMcpServer(auth.context);
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless
enableJsonResponse: true,
});
await server.connect(transport);
try {
return await transport.handleRequest(request);
} finally {
await transport.close();
await server.close();
}
}
export const POST = handle;
export const GET = handle;
export const DELETE = handle;

Stateless is the right default on serverless, where consecutive requests may land on different instances. It also removes the need for the Redis-backed session store that higher-level adapters exist to provide. Build a fresh server per request: tools close over the caller's RLS-scoped client, and a shared instance risks leaking one agent's client into another's tool call.

Tools query through the agent's own client, so RLS applies exactly as it does in the browser:

server.registerTool(
'create_task',
{
title: 'Create task',
description: 'Creates a task in the current workspace.',
inputSchema: {
title: z.string().min(1).max(500).describe('Short summary of the task.'),
description: z.string().max(5000).optional(),
},
},
async ({ title, description }) => {
const { data, error } = await context.client
.from('tasks')
// account_id comes from the grant, never from the model
.insert({ account_id: context.accountId, title, description })
.select('id, title, done')
.single();
if (error) {
return toolError(`Could not create the task: ${error.message}`);
}
return toolJson(data);
},
);

Three rules that matter more than they look:

  1. account_id is never a tool argument. It comes from the verified grant, so an agent cannot reach another workspace by passing a different id and the model cannot get it wrong.
  2. Import schemas from zod/v3. The SDK is built against Zod 3. If your app runs Zod 4, passing a v4 schema fails at registration with an unhelpful error.
  3. Return isError in-band rather than throwing. A thrown error becomes a JSON-RPC protocol error, which the agent reads as "the server broke" rather than "that operation did not work".

The demonstration that proves the design

One unchanged token, three commands, and the tenancy boundary moves. This is the clearest way to see what the grant table is doing:

POST /api/mcp/makerkit → 403 access_denied

The user owns that workspace. RLS would happily serve the data. The agent is refused because the user granted it only their personal account.

insert into public.mcp_client_grants (user_id, client_id, account_id)
values ('31a03e74-…', '4805c0a6-…', '5deaa894-…');
POST /api/mcp/makerkit → 200, returns the workspace

Nothing about the token changed. No re-authorization, no new scope, no new session. One row moved the boundary. That is least privilege sitting above RLS, and it is why the grant table earns its place.

What the documentation gets wrong

Seven gaps cost real time. All were verified against a running local Supabase in August 2026.

  1. name is silently ignored on client registration. The correct fields are client_name and client_uri, per RFC 7591. Follow the getting-started guide and your consent screen shows a blank name.
  2. New-style sb_secret_... keys must go in the apikey header. Sent only as Authorization: Bearer, they return 401 no_authorization. The legacy service role JWT works in either header, which makes this easy to misdiagnose as a key-format problem when it is a header problem.
  3. RFC 8414 path-insertion discovery returns 404 locally. The MCP guide tells clients to use /.well-known/oauth-authorization-server/auth/v1, which 404s against a local instance. Only the suffix form /auth/v1/.well-known/oauth-authorization-server answers. OpenID Connect discovery at <issuer>/.well-known/openid-configuration answers on local and hosted projects alike, though its own path-insertion form 404s locally too. The MCP spec requires clients to support both discovery mechanisms, so this is survivable, but a client implementing only RFC 8414 cannot discover a local Supabase.
  4. code_challenge_methods_supported advertises plain. OAuth 2.1 and the MCP specification both require S256. Nothing to configure away, but worth knowing.
  5. Supabase auto-approves repeat authorizations. After the first consent, getAuthorizationDetails returns a redirect carrying a code, and your consent screen never renders again. Your workspace picker therefore runs exactly once, which makes a connected-apps settings screen load-bearing rather than optional: it is the only place a grant can later be edited.
  6. scopes_supported includes offline_access, which the prose docs never mention. Leave it out of your own protected resource metadata regardless: the MCP spec tells resource servers not to advertise it, since a refresh token is a client concern.
  7. Grants and RLS are different questions. A grant row survives the user losing team membership, because it references accounts rather than memberships. That is not a data breach, since RLS still denies the underlying rows, but it means the grant answers "which workspace is this request for" and RLS answers "may this user see it". Inverting those is the obvious mistake.

The MFA problem nobody mentions

Enabling MFA silently breaks agent access, and the failure returns empty results rather than an error. This is the sharpest example of MCP grafting an agent-shaped credential onto an auth model designed for humans.

The OAuth authorization code flow issues tokens with aal: "aal1". Makerkit's is_mfa_compliant() returns false for any user with a verified factor unless the JWT says aal2. An agent can never step up, because there is no interactive TOTP prompt in an agent flow. Measured against a running database, simulating an agent's claims:

Scenariois_mfa_compliant()Accounts visible
Before the user enrols MFAtrue5
After the user enrols MFAfalse0

Users who never enable MFA are unaffected, so this hits a minority, but it hits exactly the security-conscious minority. Two mitigations worth considering:

  • Resolve the account slug with an admin client rather than the agent's, so slug lookup does not depend on an MFA-gated table. Authorization is still the grant check that follows.
  • Add a narrow exemption to the restrictive MFA policy on the tables agents must read: using (public.is_mfa_compliant() or (auth.jwt() ->> 'client_id') is not null). The argument is that the MFA gate exists to stop a stolen password session reaching data, and an OAuth client token is a separately consented, individually revocable credential rather than a weaker user session.

Note that requiring MFA before an agent may connect makes this worse rather than better. Every MCP user would then have a factor, every agent token is aal1, and every agent hits the wall. MFA is a property of a login ceremony, and an agent never performs one.

Quick Recommendation

A remote MCP server with OAuth is best for:

  • Multi-tenant SaaS products whose customers want AI agents acting on their data
  • Teams that already enforce access control with Row Level Security
  • Products where per-user attribution and instant revocation are requirements

Skip it if:

  • Your MCP server is personal tooling on your own machine, where stdio and an env var are simpler
  • Your data model has no tenancy, in which case the grant table is overhead
  • You need custom OAuth scopes, which Supabase does not support today

Our pick: put the account in the URL, enforce it with your own grant table, and let RLS do the rest. The token cannot carry tenancy, so something you own has to.

FAQ

Frequently Asked Questions

What is a remote MCP server?
A remote MCP server is an HTTP endpoint that exposes your application's data and actions to AI agents, acting as an OAuth 2.1 resource server. Agents authenticate as your existing users through a consent flow you control, then call tools over the Model Context Protocol. It differs from a local MCP server, which runs as a process on the developer's machine and typically authenticates with an API key.
Does Supabase provide an MCP server?
No. Supabase provides the OAuth 2.1 authorization server, which lets AI agents authenticate as your existing users. You build the MCP server itself, which acts as the OAuth resource server. Supabase states this in its own documentation.
Do OAuth scopes control database access?
No. Supabase supports four scopes, openid, email, profile and phone, and they control what appears in ID tokens and the UserInfo endpoint. They do not control table access. Database access is decided by Row Level Security policies, which apply to OAuth clients exactly as they apply to users.
How do I stop an AI agent reaching the wrong tenant?
Put the account in the MCP server URL and enforce it with a grant table you own. Supabase ignores the RFC 8707 resource parameter, so a token obtained for one workspace is identical to one obtained for another. Check the account in the URL against a stored grant for that user and client before serving any tool call.
Can I validate MCP tokens by checking the audience claim?
Not with Supabase today. Every token carries aud: authenticated, a value shared by every token the project issues. Verify the signature and expiry against JWKS, pin the issuer, and require the client_id claim, which only the OAuth server mints and which rejects first-party session tokens.
What happens when a user enables MFA?
Agent access to MFA-gated tables stops, silently. The OAuth flow issues aal1 tokens and an agent cannot step up to aal2, so any restrictive policy calling is_mfa_compliant() returns false. Users who never enable MFA are unaffected. The fix is a narrow client_id exemption on the tables agents need to read.
Should I use mcp-handler or the MCP SDK directly?
Use the SDK directly for this case. Version 1.30 exports WebStandardStreamableHTTPServerTransport, whose handleRequest takes a Request and returns a Response, which is already the shape of a Next.js route handler. Most of what an adapter provides is stateful session plumbing you do not need when running stateless.

Next Steps

The same OAuth server powers a "Sign in with your app" flow for developer platform integrations, which is worth building once the MCP work is in place. For the wider AI workflow around this, see Claude Code Best Practices for Production SaaS and Build a SaaS with Claude Code.

If you want the multi-tenant foundation this builds on, accounts, memberships, roles and RLS already tested, Makerkit ships it. Note the naming: the Makerkit MCP server is developer tooling for building with AI, which is a different thing from the customer-facing MCP server this guide builds.