Noodle Seed
Guides

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 systemResponsibility
App userAdds the deployed MCP URL to a client and completes the normal sign-in and consent flow.
App developerPublishes standards-compliant OAuth discovery, registration, authorization, token, refresh, and JWKS behavior.
Noodle SeedAdvertises the configured issuer, verifies resource-bound access tokens, diagnoses readiness, and runs the tools.
MCP clientDiscovers 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:

  1. Allow only the exact UI origin. If browser JavaScript calls the authorization server, enable credentialed CORS for that origin and no others.
  2. Issue the authorization-server session and CSRF cookies needed by cross-site POSTs with SameSite=None; Secure.
  3. 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.
  4. 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 = True

The 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:

  • allowedHttpsOrigins for exact HTTPS origins, including an explicit non-default port when needed.
  • allowedHttpsHostSuffixes for 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> --json

Match 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 authExpected authorization server
customers with direct or federated OIDCThe configured tenant issuer
customers with a managed Firebase or Microsoft bridgeThe Noodle Seed authorization server
owner-onlyThe 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 issuerRequired primary discovery URL
https://id.example.comhttps://id.example.com/.well-known/oauth-authorization-server
https://id.example.com/oauthhttps://id.example.com/.well-known/oauth-authorization-server/oauth

The primary URL must:

  • Be publicly reachable without cookies or an existing login.
  • Return HTTP 200 directly, without a 301, 302, 307, or 308 login redirect.
  • Return a JSON object.
  • Contain an issuer value 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-prod

The 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.ts

It 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 --json

Each 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 customers

A 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 1

Omit --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 devtools

When 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.

  1. 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.
  2. 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.
  3. 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.
  4. Restrict that trust to development client credentials, audience, API, and data.
  5. Run the delegated tool until its binding reads Exchange verified.
  6. Use hosted preview or noodle auth doctor --live to 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/v1

Configure the Supabase OAuth server as follows:

  1. 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.
  2. Set the Site URL to http://localhost:3000 and 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.
  3. Use an asymmetric Auth signing key so the issuer publishes a public JWKS that Noodle can verify.
  4. Keep one stable audience for this app and environment in customerAuth.oidc. Do not use the random local Devtools URL as that configured audience.
  5. Enable a custom access-token hook that changes aud only when the exact OAuth client_id has 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.ts

The 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/microsoft

Keep 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:

  1. The user enters the deployed MCP server URL in a compatible client.
  2. The client reads protected-resource metadata and discovers the configured authorization server.
  3. The client dynamically registers a public OAuth client, or uses another standards-supported registration method.
  4. The browser opens the app's authorization page.
  5. The user signs in and grants access.
  6. The authorization server returns an authorization code bound to PKCE and the MCP resource.
  7. The client exchanges the code, stores the access and refresh tokens, and calls the MCP endpoint.
  8. 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 7591 registration_endpoint. Support authorization code, refresh tokens, PKCE S256, public clients using token endpoint auth method none, exact registered redirect URIs, and RFC 8707 resource on 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 with noodle 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:

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.

On this page