Customer auth for remote MCP clients
Connect one customer-protected MCP server to standards-based clients with OAuth discovery, DCR, PKCE, refresh tokens, and resource-bound access tokens.
Noodle Seed verifies customer access tokens and protects your MCP tools. For direct or federated OIDC, your application developer still owns the authorization server that signs users in and issues those tokens.
This boundary keeps the architecture simple:
| Person or system | Responsibility |
|---|---|
| App user | Adds the deployed MCP URL to a client and completes the normal sign-in and consent flow. |
| App developer | Publishes standards-compliant OAuth discovery, registration, authorization, token, refresh, and JWKS behavior. |
| Noodle Seed | Advertises the configured issuer, verifies resource-bound access tokens, diagnoses readiness, and runs the tools. |
| MCP client | Discovers the issuer, registers or uses a configured OAuth client, opens sign-in, refreshes tokens, and calls the MCP server. |
Noodle Seed does not put an OAuth compatibility proxy in front of a direct or federated issuer. The app developer fixes the issuer once, and every standards-based remote MCP client benefits.
Choose the customer auth shape
Use direct OIDC as the normal path when one issuer protects the app:
auth: customerAuth.oidc({
issuer: 'https://id.example.com/oauth',
audience: 'acme-support-prod',
})Use federated OIDC when an exact allowlist of issuers can identify customers:
auth: customerAuth.federatedOidc({
issuers: [
{
issuer: 'https://id.example.com/oauth',
audience: 'acme-support-prod',
},
],
})Managed Firebase and Microsoft adapters remain available when an existing provider cannot meet the direct remote-MCP authorization-server contract. Those adapters use the Noodle-managed authorization server and do not project customer endpoint routes:
auth: customerAuth.firebase({
projectId: variable('FIREBASE_PROJECT_ID'),
apiKey: variable('FIREBASE_WEB_API_KEY'),
authDomain: variable('FIREBASE_AUTH_DOMAIN'),
})Keep the interactive authorization flow same-origin
The MCP client, or Devtools during local testing, opens the authorization_endpoint published by your
authorization server. Noodle does not submit the login or consent form and does not manage the authorization
server's session or CSRF cookies.
Host the login and consent UI, credential POST, and authorization endpoint on the same origin. If the UI is served elsewhere for branding, reverse proxy that UI under the authorization server origin. This keeps the authorization server's cookies first-party and is the most reliable option across browsers and privacy modes.
If a separate-site UI is unavoidable, the authorization server must implement an explicit cross-site flow:
- Allow only the exact UI origin. If browser JavaScript calls the authorization server, enable credentialed CORS for that origin and no others.
- Issue the authorization-server session and CSRF cookies needed by cross-site POSTs with
SameSite=None; Secure. - Expose a bounded CSRF bootstrap endpoint that sets the cookie and returns a masked token. Fetch it with credentials, then send the token in the login POST using the framework's expected header or form field.
- Trust the exact UI origin for CSRF origin checks. Do not disable CSRF validation.
Cookies cannot be shared across unrelated registrable domains by setting a broader cookie Domain. Even
with the controls above, browser third-party-cookie restrictions can block the cookie, which is why the
same-origin or reverse-proxy design is preferred.
For a Django authorization server, the core settings are:
CSRF_TRUSTED_ORIGINS = ["https://login.example.com"]
CSRF_COOKIE_SAMESITE = "None"
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "None"
SESSION_COOKIE_SECURE = TrueThe CSRF bootstrap view should call django.middleware.csrf.get_token(request) and return that masked token
to the allowlisted UI. The UI then submits it as X-CSRFToken or csrfmiddlewaretoken, with credentials
included. Configure exact credentialed CORS at Django or the edge if the UI uses fetch. See Django's
CSRF reference for the token and origin checks.
Route calls to each customer's API
Use customerEndpoint when the verified IdP selects a different API base URL for each customer. The
customer claim contains the full base URL; tool arguments and ${user} never select it:
const customerApi = customerEndpoint('customer_api', {
allowedHttpsHostSuffixes: ['api.example.com'],
})
const api = connector('customer_api')
.version('1.0.0')
.http({
baseUrl: customerApi,
auth: {
kind: 'delegatedTokenExchange',
tokenUrl: 'https://id.example.com/oauth/token',
clientId: variable('CUSTOMER_API_CLIENT_ID'),
clientSecret: secret('CUSTOMER_API_CLIENT_SECRET'),
},
operations: {
list_records: {
type: 'read',
method: 'GET',
path: '/records',
output: z.object({ records: z.array(z.unknown()).max(100) }),
},
archive_record: {
type: 'action',
method: 'POST',
path: '/records/${args.record_id}/archive',
input: z.object({ record_id: z.string() }),
output: z.object({ archived: z.boolean() }),
},
},
})
auth: customerAuth.oidc({
issuer: 'https://id.example.com',
audience: 'acme-support-prod',
routing: {
endpoints: {
customer_api: { claim: 'tenant.api_base_url' },
},
},
})Choose exactly one non-empty policy:
allowedHttpsOriginsfor exact HTTPS origins, including an explicit non-default port when needed.allowedHttpsHostSuffixesfor an exact host or dot-boundary subdomain on port 443.
Use the narrowest service suffix or exact origins. A routed connector does not also declare
allowedOrigins; its fixed credential or token endpoint remains an independently validated HTTPS URL.
For federated OIDC, every issuer maps every endpoint used by the app, although each issuer may use a
different claim path. Managed Firebase and Microsoft bridges do not project customer routes.
At both connector and operation level, auth must be omitted or use delegatedTokenExchange. The compiler
validates the concrete connector definition emitted from TypeScript, including connector defaults and
operation overrides. A bearer, API-key, client-credentials, or managed-provider fallback is rejected even
when it exists only for local mode. Use operation fakes while leaving auth declarative. The
customer_endpoint_unsupported_auth diagnostic reports the exact failing path and auth kind.
Routed reads work in tools, including their declared nested connector calls. A routed action—including one
reached through a connector wrapper—requires exact annotations.confirm: true; omitted or false fails
compilation with customer_endpoint_action_unsupported. Routed resources, prompts, and ambient context
remain unsupported and fail with customer_endpoint_surface_unsupported.
Use the normal action helper and opt into runtime confirmation explicitly:
tool('archive_record', {
description: 'Archive one customer record.',
input: z.object({ record_id: z.string() }),
output: z.object({ archived: z.boolean() }),
annotations: annotations.openAction({ destructive: false, confirm: true }),
fulfil({ input, connectors }) {
return connectors.api.archiveRecord({ record_id: input.record_id })
},
})On a bidirectional MCP transport whose client negotiated form elicitation, Noodle Seed sends the standard confirmation form and executes only after an affirmative response. The current stateless hosted MCP transport cannot initiate that exchange. To use the MCP host's native write-approval UI instead, opt in explicitly on the server:
interactions: {
confirmationFallback: 'host',
},This fallback trusts the host to have collected approval before the tool call reaches Noodle Seed; it is
never inferred from a client name and does not replace authentication, authorization, policy, or accurate
action/destructive annotations. Omit it when the connected hosts are not trusted to provide that approval;
confirmation then fails closed with interaction_unavailable when the standard form exchange is
unavailable.
Noodle Seed verifies the configured stable audience, then associates the verified caller with the exact
transport-derived MCP resource before reading a route claim. The
resolved URL is request-private: it never enters ${user}, artifacts, logs, model output, widgets,
public confirmation review, broker cache keys, or delegated-exchange assertions. Initial missing, malformed,
and disallowed values all return connector_route_unavailable before credential lookup or connector
egress. Preparation stores only route { key, fingerprint } pairs in the private server-held continuation.
Acceptance re-resolves the current request route; a missing or changed binding returns
invalid_continuation before policy, credentials, or egress, and the current frozen snapshot is then reused
for the action and nested/later calls. Tool discovery remains based only on roles and scopes.
Embedded-assistant sessions support delegated token exchange for static connectors, but they do not carry direct/federated MCP OIDC route claims. A customer-routed tool called from an embedded-assistant session therefore fails closed. Use the customer-protected MCP endpoint for routed tools until a separate authenticated route-persistence contract exists.
Authorize individual tools
Endpoint auth proves who the customer is. Add a typed rule to a tool when only part of that authenticated customer population may discover or invoke it:
auth: customerAuth.oidc({
issuer: 'https://id.example.com/oauth',
audience: 'acme-support-prod',
claims: {
roles: 'permissions.roles',
scopes: 'permissions.scopes',
},
})
tool('list_org_apps', {
authorization: {
requiredScopes: ['org_apps:read'],
allowedRoles: ['org_admin', 'org_member'],
},
// input, output, and fulfilment...
})All requiredScopes must match and any one allowedRoles value may match. When both lists are present,
both checks apply. Omit authorization for a tool that every caller admitted to the endpoint may use.
Role values are trusted only from the claim path you configure. Scopes use the configured path when
present; direct OIDC otherwise reads the standard scope, scp, or scopes claims. Do not derive tool
authorization from request arguments, page context, connector responses, email domains, or an unverified
generic role claim.
An ineligible tool is omitted from tools/list, but hiding it is not the security boundary: a direct
tools/call is independently denied before its arguments or fulfilment run. Missing scopes return the
standard MCP insufficient_scope challenge so a capable client can request step-up consent. Role denials
remain generic and do not reveal configured role names.
Diagnose an unexpected authorization server
Adding embeddedAssistant(...) does not choose who may call the MCP endpoint or which authorization server
its protected-resource metadata advertises. Inspect the exact active target before changing auth:
noodle deployments list --org <org> --app <app> --env <env> --jsonMatch the endpoint to the active deployment ID, server version, and access mode. Then compare the
authorization_servers value from its protected-resource metadata:
| Active access and auth | Expected authorization server |
|---|---|
customers with direct or federated OIDC | The configured tenant issuer |
customers with a managed Firebase or Microsoft bridge | The Noodle Seed authorization server |
owner-only | The Noodle platform authorization server |
If the exact active deployment uses direct or federated customers auth but advertises the platform issuer,
report customer_auth_state_inconsistent. Share only the endpoint, active deployment ID, server version, and
sanitized protected-resource metadata through a private support channel. Never share bearer tokens, refresh
tokens, client secrets, or credential files. Do not proxy or rewrite metadata, rotate credentials, or
redeploy repeatedly to conceal the mismatch.
The rest of this guide applies to direct and federated OIDC. Built-in adapters use their managed authorization-server path.
What the app developer must publish
1. Direct RFC 8414 discovery
For an issuer with a path, OAuth clients try the path-inserted authorization-server metadata URL first.
| Configured issuer | Required primary discovery URL |
|---|---|
https://id.example.com | https://id.example.com/.well-known/oauth-authorization-server |
https://id.example.com/oauth | https://id.example.com/.well-known/oauth-authorization-server/oauth |
The primary URL must:
- Be publicly reachable without cookies or an existing login.
- Return HTTP
200directly, without a301,302,307, or308login redirect. - Return a JSON object.
- Contain an
issuervalue that exactly matches the configured issuer.
2. Complete OAuth metadata
A minimal interoperable document looks like this:
{
"issuer": "https://id.example.com/oauth",
"authorization_endpoint": "https://id.example.com/oauth/authorize",
"token_endpoint": "https://id.example.com/oauth/token",
"jwks_uri": "https://id.example.com/oauth/jwks",
"registration_endpoint": "https://id.example.com/oauth/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"]
}The endpoints must use HTTPS. registration_endpoint is Dynamic Client Registration under RFC 7591.
Remote MCP clients are public OAuth clients, so they use PKCE rather than a client secret. Support:
- Authorization code flow.
- PKCE with
S256. - Public clients with token endpoint auth method
none. - Refresh tokens, including safe rotation and revocation.
- The exact redirect URIs submitted during registration.
3. Resource-bound access tokens
Implement the RFC 8707 resource parameter on authorization, authorization-code exchange, and refresh.
Validate the exact requested MCP URL, then map every approved version of one app/environment to the stable
audience configured in customerAuth. Use a different audience for every other app or environment.
For example:
resource=https://acme.cloud.noodleseed.dev/support/mcp
aud=acme-support-prodThe same audience may cover approved /v1/mcp and /v2/mcp resources for this environment. Do not accept
arbitrary resource URLs or reuse acme-support-prod for another app/environment. Noodle Seed rejects tokens
whose issuer or audience does not match the configured customer auth.
4. A public JWKS
jwks_uri must return HTTP 200 JSON with at least one public RSA, EC, or OKP signing key. Never publish
private fields such as d, p, q, or a symmetric k.
Check before sharing
Run the read-only diagnostic against the authored server:
noodle auth doctor src/server.tsIt verifies the primary RFC 8414 route, exact issuer, endpoints, authorization-code and refresh support, PKCE, public-client support, DCR advertisement, and public JWKS. Its issuer-readiness probes send bounded GET requests only. They do not submit a registration request, create a client, or mutate the authorization server.
For machine-readable findings:
noodle auth doctor src/server.ts --jsonEach finding has a stable code, level, optional issuer, message, and repair instruction. A direct or
federated issuer is ready only when every required check passes.
Then deploy:
noodle deploy src/server.ts --access customersA successful customer deploy runs the same readiness diagnostic. OAuth failures are warnings, not deploy failures, because the MCP artifact is already live and the app developer can repair the issuer independently. JSON output includes:
{
"ok": true,
"data": {
"authReadiness": {
"ready": false,
"checks": [
{
"code": "oauth_metadata_discovery",
"level": "FAIL",
"issuer": "https://id.example.com/oauth",
"message": "the attempted metadata URL and safe HTTP status",
"fix": "Serve the path-inserted RFC 8414 URL directly as HTTP 200 JSON."
}
]
}
}
}Repair the issuer, rerun noodle auth doctor src/server.ts, and reconnect the client. You do not need to
redeploy when only the external authorization server changed.
To verify one deployed customer token and its delegated bindings without calling a business API:
NOODLE_CUSTOMER_TOKEN=<short-lived-token> noodle auth doctor src/server.ts --live \
--org acme --app support --env prod --version 1Omit --version for the active unversioned endpoint. The reported customer resource must match the exact
MCP URL under test. Never paste the token into chat or support messages.
Test customer auth in local Devtools
Run the app and open the preview URL:
noodle dev
noodle devtoolsWhen server.auth is direct or federated OIDC, the local MCP endpoint is protected and Devtools shows
Sign in to test. Direct OIDC uses its configured issuer. Federated OIDC shows the configured identity
providers and requires one exact selection; the first declared issuer is the visible default. Devtools
discovers that issuer, dynamically registers a native public client, opens PKCE sign-in in a popup, and then
runs tools through a server-side bearer forwarder. The access token, refresh token, client registration,
state, and verifier stay only in the local Node process; they are never returned to the page, widget,
activity log, or filesystem. Switching a federated provider clears the previous provider's local
credential, registration, and pending sign-in before starting the next flow.
Devtools needs no account or link. An unlinked project uses local/<project-app>/dev; run noodle link only
when you want local Devtools to mirror a deployed app's hosted target. See
ADR 0048
for the complete local target-precedence contract.
Your authorization server must accept the exact loopback callback submitted during DCR and the exact
http://127.0.0.1:<port>/o/.../mcp RFC 8707 resource on authorization, code exchange, and refresh. Map that
approved local resource to the stable audience declared for that issuer in customerAuth.oidc or
customerAuth.federatedOidc. Every federated issuer must advertise and accept the same exact local MCP
resource independently. Preview ports are normally OS-assigned, so do not hard-code one callback unless you
also use noodle dev --preview-port <port> or noodle devtools --port <port>.
Test delegated exchange locally
Local customer OIDC sign-in and delegated-exchange assertion trust are two distinct boundaries. OIDC proves
the customer calling the local MCP server; Devtools uses a separate local issuer only for the RFC 8693
assertion sent to your downstream token endpoint. This requires no server.ts change or additional local
flag, environment variable, or config surface.
- Configure the OIDC authorization server exactly as above for the loopback callback and RFC 8707 resource. Do not add the Devtools assertion key to OIDC issuer metadata or change its signing keys.
- Start Devtools, complete customer sign-in, then select Copy setup JSON from Local delegated exchange. The one setup document contains the issuer, public JWKS, tenant, deployment, every connector binding, an optional operation for an operation-level binding, and its resolved audience.
- Pin the document only in the customer-owned development RFC 8693 token endpoint. That endpoint compares the assertion fields and its own clock; no customer or downstream tokens or client secrets enter the browser.
- Restrict that trust to development client credentials, audience, API, and data.
- Run the delegated tool until its binding reads Exchange verified.
- Use hosted preview or
noodle auth doctor --liveto prove the production platform issuer.
Never trust the Devtools issuer in production. Anyone holding the project-local private key could impersonate a customer to a token endpoint that accepts that issuer.
Supabase Auth through direct OIDC
Supabase Auth can be the authorization server for customerAuth.oidc; no Supabase connector is required.
The Supabase MCP authentication guide
documents OAuth discovery, PKCE, Dynamic Client Registration, and the default issuer:
https://<project-ref>.supabase.co/auth/v1Configure the Supabase OAuth server as follows:
- Enable the OAuth server and Dynamic Client Registration. Devtools registers a native public client with a generated loopback callback, so a hard-coded OAuth client is not sufficient.
- Set the Site URL to
http://localhost:3000and the Authorization Path to the route that renders your consent screen, such as/oauth/consent. Follow Supabase's OAuth server setup for the consent contract. - Use an asymmetric Auth signing key so the issuer publishes a public JWKS that Noodle can verify.
- Keep one stable audience for this app and environment in
customerAuth.oidc. Do not use the random local Devtools URL as that configured audience. - Enable a custom access-token hook
that changes
audonly when the exact OAuthclient_idhas an approved client-to-audience mapping. Dynamic registration alone is not approval. Unknown OAuth clients and ordinary browser sessions retain their original audience.
Copy the SQL and least-privilege grants from the
customer-auth example's Supabase access-token hook,
then replace <approved-oauth-client-id> with the reviewed client and <stable-mcp-audience> with the exact
value configured in customerAuth.oidc.
Select that function under Supabase Auth Hooks, then verify the integration in increasing evidence order:
noodle validate src/server.ts --json
noodle auth doctor src/server.ts --json
noodle test src/server.ts --json
noodle devtools src/server.tsThe doctor proves advertised metadata and JWKS readiness. For a protected app, noodle test proves the
anonymous 401 and exact protected-resource metadata boundary and reports interactiveRequired: true.
Complete Supabase consent in Devtools and load the tool list to prove DCR, PKCE, token issuance, the stable
audience, JWT verification, and exact local-resource binding together. Supabase's MCP guide does not
currently document automatic RFC 8707 resource-to-audience mapping, so keep the access-token hook rather
than treating a passing metadata check as complete authentication proof.
For customerAuth.firebase, Devtools verifies a Firebase ID token directly against the configured project
and binds the verified customer to the exact local MCP URL. This is intentionally different from deployment:
the hosted flow verifies Firebase and then issues a Noodle resource-bound token, while the loopback author
loop has no local Noodle issuer. Hosted MCP endpoints still reject raw Firebase tokens.
The default local page follows Firebase's
Google popup sign-in. Add 127.0.0.1 under
Firebase Authentication → Settings → Authorized domains, and enable Google as a sign-in provider. Firebase
no longer adds a local development domain automatically
for new projects. If your app uses password, email-link, SAML, OIDC, or another existing Firebase UI,
configure its HTTPS URL:
auth: customerAuth.firebase({
projectId: variable('FIREBASE_PROJECT_ID'),
apiKey: variable('FIREBASE_WEB_API_KEY'),
authDomain: variable('FIREBASE_AUTH_DOMAIN'),
authorizeUrl: 'https://app.example.com/devtools-auth',
})Devtools opens that URL with state, redirect_uri, resource, provider=firebase, project_id, and the
optional tenant_id. After your normal Firebase UI signs the user in, submit
application/x-www-form-urlencoded to the exact redirect_uri with state, id_token, optional
refresh_token, and optional expires_in. Never put either token in the callback query string. The callback
and port are generated per preview, so read redirect_uri from each request rather than hard-coding it.
Firebase ID and refresh tokens remain in the local Node process. Devtools refreshes the ID token as needed
and forwards only that ID token to its own loopback MCP endpoint. When the sign-in response includes a
refresh token, Devtools also keeps an exact-resource/provider/user-bound copy in process memory so the normal
credential broker can test Firebase delegatedOAuth and delegatedSessionCookie tools locally. A custom
Firebase UI must include refresh_token in its form post for those tools. Nothing is written to disk or
returned to the browser; deployment still uses the hosted sealed credential store.
For customerAuth.microsoft, add this redirect URI to the Entra app registration as a Web platform:
http://localhost/auth/callback/microsoftKeep the preview port dynamic. Microsoft ignores it when matching a localhost redirect URI
but still matches the path, so Devtools can supply its OS-assigned port to one registered URI. Reuse the hosted
bridge's confidential client ID and managed secret; do not enable public-client flow just for Devtools.
The local host uses Microsoft's authorization-code flow with PKCE and an OIDC nonce. It exchanges the code in Node, retains the refresh token in memory, discards the Graph access token, and sends only the ID token to the loopback MCP endpoint. That endpoint validates its signature, tenant issuer, client-ID audience, expiry, and authorization claims. Secrets and exchange state never enter browser APIs.
The refresh token also feeds the normal credential broker through an exact-resource/provider/user-bound,
process-memory source, enabling Microsoft delegatedOAuth tools locally. The record ends with the auth
session; deployment still uses the hosted sealed store.
On token-exchange rejection, the callback stays open with a safe OAuth/AADSTS code and repair instruction, never raw responses, trace IDs, secrets, or tokens. Fix it and choose Try sign-in again; no restart is needed. Successful callbacks still close automatically.
Authenticated widgets run in an opaque-origin sandbox and ask the parent host to call tools. Unsafe or incompletely annotated widget calls require confirmation; Chat automatically receives only explicitly read-only, non-destructive, closed-world tools. Design mode is unavailable in an authenticated preview until its DOM bridge is host-mediated. Apps without customer auth keep the open, account-free local path.
Direct OIDC, federated OIDC, Firebase, and Microsoft are supported. Other bridge providers fail with a clear
unsupported message instead of silently testing anonymously. A --tunnel URL is a different public resource
and must be allowlisted separately; Devtools never reuses its loopback token for that URL.
What the user experiences
When everything is ready:
- The user enters the deployed MCP server URL in a compatible client.
- The client reads protected-resource metadata and discovers the configured authorization server.
- The client dynamically registers a public OAuth client, or uses another standards-supported registration method.
- The browser opens the app's authorization page.
- The user signs in and grants access.
- The authorization server returns an authorization code bound to PKCE and the MCP resource.
- The client exchanges the code, stores the access and refresh tokens, and calls the MCP endpoint.
- Noodle Seed verifies issuer, signature, expiry, and the configured stable audience, then binds the exact requested MCP resource to the private request context before any tool runs.
If the client asks the user for a client ID and secret immediately, discovery or Dynamic Client Registration did not complete. That prompt is a useful compatibility fallback, but it is not the preferred customer experience.
Copy this request to the app developer
Please make our authorization server compatible with standards-based remote MCP clients. Publish the path-inserted RFC 8414 metadata URL as unauthenticated HTTP 200 JSON with the exact issuer and HTTPS
authorization_endpoint,token_endpoint,jwks_uri, and RFC 7591registration_endpoint. Support authorization code, refresh tokens, PKCE S256, public clients using token endpoint auth methodnone, exact registered redirect URIs, and RFC 8707resourceon authorize, code exchange, and refresh. Allowlist each exact MCP resource and map approved versions of one app/environment to its configured stable access-token audience; never reuse that audience across apps or environments. Publish a public JWKS with no private key material. Do not redirect the metadata URL to login. Verify the result withnoodle auth doctor src/server.ts; the command is read-only and will identify each remaining gap.
Client-specific notes
The OAuth contract above is host-neutral. Client features and availability change independently:
- Gemini Spark's custom Connected Apps documentation says it accepts an MCP server URL and offers manual credentials under Advanced features when the server does not support DCR.
- Claude's remote MCP documentation explicitly supports DCR, manual client credentials as a fallback, token expiry, and refresh.
- ChatGPT's current developer-mode documentation requires a working OAuth flow and calls out refresh-token configuration. Follow its current setup and review requirements in addition to this protocol checklist.
Use the clients' current official documentation when running release acceptance. A client-specific smoke is evidence for that client and date, not a permanent protocol guarantee.