Integration

MCP Server Integration

[object Object]

An MCP server that exposes its tools over HTTP can use auth-service as its authorization server. The MCP client signs the user in with the OAuth 2.1 Authorization Code flow and PKCE, receives a JWT access token whose aud is your MCP server, and sends it as a Bearer token. The MCP server verifies the token against the public JWKS, with no shared secret, and reads the roles, permissions, features and org_id claims.

Architecture

MCP client
        │
        │  OAuth 2.1 Authorization Code + PKCE
        ▼
┌──────────────────────┐
│   auth-service       │  ← Authorization server
│   (BetterAuth core)  │
└──────────┬───────────┘
           │  JWT access token (aud = MCP server URL)
           ▼
┌──────────────────────┐
│  MCP server          │  ← Resource server
│  (your MCP backend)  │  verifies the JWT via the JWKS
└──────────────────────┘

What auth-service provides to MCP clients

CapabilityState
Authorization server metadataGET /.well-known/oauth-authorization-server and GET /.well-known/openid-configuration, both at the issuer root
Authorization Code + PKCE (S256 only)Yes. PKCE is required for every application
Refresh tokensOnly when the offline_access scope is granted
Dynamic Client Registration (/oauth2/register)Not enabled. The metadata has no registration_endpoint
Client ID Metadata DocumentsNot enabled
Protected Resource Metadata (RFC 9728, /.well-known/oauth-protected-resource)Not served by auth-service. Your MCP server has to publish it if its clients need it
Every client is registered by an administrator, so an MCP client must be configured with the client_id (the application slug) in advance. A client that can only register itself dynamically cannot obtain a token from this service.

Step 1 — Register the application

Create a public application whose url is the address of your MCP server:

POST /api/admin/applications
Content-Type: application/json

{
  "name": "My MCP Server",
  "slug": "my-mcp-server",
  "description": "MCP server for document management",
  "url": "https://mcp.example.com",
  "isPublic": true,
  "allowedScopes": ["openid", "profile", "email", "roles", "permissions", "features", "org", "offline_access"],
  "redirectUris": ["https://mcp.example.com/callback"],
  "isMfaRequired": false
}

Key fields:

  • slug is the OAuth client_id.
  • url is the resource identifier of the application. The authorization request must carry resource=<url> to get a JWT access token whose aud is that URL. Without a url there is no resource to request.
  • isPublic: true creates a client without a secret. PKCE is required, and the client_credentials grant is not available to it.
  • allowedScopes limits what the client may request. Include roles, permissions, features and org for the matching claims, and offline_access if you want a refresh token.
  • redirectUris are matched exactly. Use the callback URL(s) your MCP client really redirects to.
A public application is open to every account: the first token request of a user without access creates it automatically (and assigns the default role and plan). To restrict who can use the MCP server, register a confidential application instead (isPublic: false) and grant users explicitly. See Integrating a third-party application.

Step 2 — Sign the user in

Authorization URL

Generate a PKCE verifier and send the user to the authorization endpoint:

import { createHash, randomBytes } from "node:crypto";

const issuer = "https://auth.example.com";
const resource = "https://mcp.example.com"; // the application's url

const verifier = randomBytes(32).toString("base64url"); // 43 characters
const challenge = createHash("sha256").update(verifier).digest("base64url");
const state = randomBytes(16).toString("base64url");

const authorizeUrl = new URL(`${issuer}/api/auth/oauth2/authorize`);
authorizeUrl.search = new URLSearchParams({
  client_id: "my-mcp-server", // application slug
  response_type: "code",
  redirect_uri: "https://mcp.example.com/callback",
  scope: "openid profile email roles permissions features org offline_access",
  resource,
  code_challenge: challenge,
  code_challenge_method: "S256",
  state,
}).toString();

// Open authorizeUrl in the user's browser.

The redirect back to redirect_uri carries code, state and iss. Check that state and iss are the expected values before exchanging the code.

Token exchange

const tokenResponse = await fetch(`${issuer}/api/auth/oauth2/token`, {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code: "<authorization_code_from_callback>",
    redirect_uri: "https://mcp.example.com/callback",
    client_id: "my-mcp-server",
    code_verifier: verifier,
  }),
});

const { access_token, id_token, refresh_token } = await tokenResponse.json();

The access_token is a JWT because the authorization request carried resource; the code exchange inherits that resource. refresh_token is present only because offline_access was requested and granted.

Step 3 — Verify the token in the MCP server

JWKS endpoint

GET https://auth.example.com/api/auth/jwks

The keys are Ed25519 by default (kty: OKP, crv: Ed25519), so verify with EdDSA. An instance configured with JWT_PRIMARY_ALG=RS256 (or one of the Okta, Auth0 and Keycloak compatibility profiles) signs with RS256 instead. Keys rotate monthly and the previous key stays published for another month, so cache the set by kid and refetch on an unknown kid.

Access token contents

{
  "iss": "https://auth.example.com",
  "sub": "<user id>",
  "aud": ["https://mcp.example.com", "https://auth.example.com/api/auth/oauth2/userinfo"],
  "azp": "my-mcp-server",
  "client_id": "my-mcp-server",
  "scope": "openid profile email roles permissions features org offline_access",
  "roles": ["editor"],
  "permissions": ["documents", "documents.write"],
  "features": { "apiAccess": true },
  "org_id": "<organization id>",
  "sid": "<session id>",
  "iat": 1792835793,
  "exp": 1792839393,
  "jti": "…"
}
  • aud is an array when the openid scope was granted (your resource plus the userinfo endpoint) and a plain string otherwise. A verifier that checks for containment, such as the audience option of jose or PyJWT, handles both.
  • permissions use the form resource (read) and resource.write (write).
  • org_id is present only when the org scope was requested and an organization was bound during sign-in.
  • email, email_verified, name, picture and plan appear when the matching scope (email, profile, features) was granted. See OIDC claims.
  • The access token lives for one hour.

Verify the JWT and use the claims

The example below is a stateless MCP server over Streamable HTTP. The SDK middleware requireBearerAuth calls the verifier and returns 401 with a WWW-Authenticate header when it fails; the claims reach the tool through extra.authInfo.

import express from "express";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
import { InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
import type { OAuthTokenVerifier } from "@modelcontextprotocol/sdk/server/auth/provider.js";

const ISSUER = "https://auth.example.com";
const RESOURCE = "https://mcp.example.com"; // the application's url
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/api/auth/jwks`));

interface Claims {
  sub: string;
  roles: string[];
  features: Record<string, unknown>;
  org_id?: string;
}

const verifier: OAuthTokenVerifier = {
  async verifyAccessToken(token) {
    try {
      const { payload } = await jwtVerify(token, JWKS, {
        algorithms: ["EdDSA"],
        issuer: ISSUER,
        audience: RESOURCE, // matches when the `aud` array contains it
      });
      const claims: Claims = {
        sub: payload.sub ?? "",
        roles: Array.isArray(payload.roles) ? (payload.roles as string[]) : [],
        features:
          typeof payload.features === "object" && payload.features !== null
            ? (payload.features as Record<string, unknown>)
            : {},
        ...(typeof payload.org_id === "string" ? { org_id: payload.org_id } : {}),
      };
      return {
        token,
        clientId: String(payload.azp),
        scopes: String(payload.scope ?? "").split(" ").filter(Boolean),
        expiresAt: payload.exp,
        resource: new URL(RESOURCE),
        extra: { ...claims },
      };
    } catch (error) {
      throw new InvalidTokenError(error instanceof Error ? error.message : "Invalid token");
    }
  },
};

function createServer(): McpServer {
  const server = new McpServer({ name: "my-mcp-server", version: "1.0.0" });

  server.registerTool(
    "documents_list",
    {
      description: "List documents for the authenticated user",
      inputSchema: { page: z.number().int().min(1).default(1) },
    },
    async ({ page }, extra) => {
      const claims = extra.authInfo?.extra as unknown as Claims;

      // Role check
      if (!claims.roles.includes("editor")) {
        return { isError: true, content: [{ type: "text", text: "Requires the 'editor' role" }] };
      }
      // Feature flag from the user's subscription plan
      if (claims.features.apiAccess !== true) {
        return { isError: true, content: [{ type: "text", text: "API access is not part of your plan" }] };
      }

      // Scope every query by claims.sub and, when present, claims.org_id.
      const documents = [{ id: 1, page, owner: claims.sub, orgId: claims.org_id }];
      return { content: [{ type: "text", text: JSON.stringify(documents) }] };
    },
  );

  return server;
}

const app = express();
app.use(express.json());

app.post("/mcp", requireBearerAuth({ verifier }), async (req, res) => {
  const server = createServer();
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  res.on("close", () => {
    void transport.close();
    void server.close();
  });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

app.listen(3000);

A stdio MCP server has no HTTP request and therefore no Authorization header: this flow applies to HTTP transports only.

Step 4 — Refresh the token

Exchange the refresh token before the access token expires. The server rotates it: store the refresh_token of each response.

const refreshResponse = await fetch(`${issuer}/api/auth/oauth2/token`, {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "refresh_token",
    refresh_token: "<refresh_token>",
    client_id: "my-mcp-server",
  }),
});

const { access_token: newAccessToken, refresh_token: newRefreshToken } =
  await refreshResponse.json();

Without resource the new access token keeps the resources authorised at sign-in. A resource or scope you send can only narrow them.

Step 5 — Server-to-server calls (client credentials)

An MCP server that must authenticate as itself, without a user, needs a confidential application: public applications cannot use the client_credentials grant. This is a separate registration from the public one above.

const basic = Buffer.from(`my-backend:${process.env.BACKEND_CLIENT_SECRET!}`).toString("base64");
const tokenResponse = await fetch(`${issuer}/api/auth/oauth2/token`, {
  method: "POST",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded",
    Authorization: `Basic ${basic}`, // HTTP Basic by default, not client_secret in the body
  },
  body: new URLSearchParams({
    grant_type: "client_credentials",
    resource: "https://mcp.example.com",
  }),
});

const { access_token } = await tokenResponse.json();

The resulting JWT has:

  • sub equal to the application slug (not a user ID) and scope equal to m2m;
  • aud equal to the resource URL;
  • no roles, permissions or features claims, because they are user-specific.

The resource must be the application's own url or the url of another application listed in its allowedResources. See Integrating a third-party application.

Discovery

GET https://auth.example.com/.well-known/openid-configuration
GET https://auth.example.com/.well-known/oauth-authorization-server

Excerpt of the OpenID configuration with the default settings:

{
  "issuer": "https://auth.example.com",
  "authorization_endpoint": "https://auth.example.com/api/auth/oauth2/authorize",
  "token_endpoint": "https://auth.example.com/api/auth/oauth2/token",
  "userinfo_endpoint": "https://auth.example.com/api/auth/oauth2/userinfo",
  "jwks_uri": "https://auth.example.com/api/auth/jwks",
  "end_session_endpoint": "https://auth.example.com/api/auth/oauth2/end-session",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "client_credentials", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "scopes_supported": ["openid", "profile", "email", "phone", "offline_access", "roles", "permissions", "features", "wallet:self:read", "org"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["EdDSA"]
}

Security considerations

  1. PKCE is mandatory. Every application requires it, and only S256 is accepted.
  2. Validate iss and aud. Pin iss to the issuer exactly (no trailing slash) and require aud to contain your MCP server URL, so a token minted for another application is refused.
  3. Cache the JWKS by kid. Refetch only when you meet an unknown kid; the libraries above do this.
  4. Mind revocation. When a session ends, the service revokes the access tokens bound to it, but a JWT you verify locally stays valid until its exp (one hour). If you need immediate effect, call POST /api/auth/oauth2/introspect from a confidential client.
  5. Use HTTPS for every OAuth endpoint.
  6. Keep secrets out of code. Client secrets (confidential applications) belong in environment variables or a secrets manager.
  7. Request only the scopes you need.
Copyright © 2026