MCP Server Integration
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
| Capability | State |
|---|---|
| Authorization server metadata | GET /.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 tokens | Only when the offline_access scope is granted |
Dynamic Client Registration (/oauth2/register) | Not enabled. The metadata has no registration_endpoint |
| Client ID Metadata Documents | Not 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 |
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:
slugis the OAuthclient_id.urlis the resource identifier of the application. The authorization request must carryresource=<url>to get a JWT access token whoseaudis that URL. Without aurlthere is no resource to request.isPublic: truecreates a client without a secret. PKCE is required, and theclient_credentialsgrant is not available to it.allowedScopeslimits what the client may request. Includeroles,permissions,featuresandorgfor the matching claims, andoffline_accessif you want a refresh token.redirectUrisare matched exactly. Use the callback URL(s) your MCP client really redirects to.
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": "…"
}
audis an array when theopenidscope was granted (your resource plus the userinfo endpoint) and a plain string otherwise. A verifier that checks for containment, such as theaudienceoption ofjoseor PyJWT, handles both.permissionsuse the formresource(read) andresource.write(write).org_idis present only when theorgscope was requested and an organization was bound during sign-in.email,email_verified,name,pictureandplanappear 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.
import asyncio
import json
import jwt # PyJWT: pip install "pyjwt[crypto]"
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import FastMCP
ISSUER = "https://auth.example.com"
RESOURCE = "https://mcp.example.com" # the application's url
# PyJWKClient caches the key set and refetches it when it meets an unknown kid
jwks_client = jwt.PyJWKClient(f"{ISSUER}/api/auth/jwks")
def decode(token: str) -> dict:
key = jwks_client.get_signing_key_from_jwt(token)
return jwt.decode(
token,
key.key,
algorithms=["EdDSA"],
issuer=ISSUER,
audience=RESOURCE, # matches when the `aud` array contains it
)
class AuthServiceVerifier:
async def verify_token(self, token: str) -> AccessToken | None:
try:
# PyJWKClient does blocking I/O: keep it off the event loop
claims = await asyncio.to_thread(decode, token)
except jwt.PyJWTError:
return None
return AccessToken(
token=token,
client_id=claims["azp"],
scopes=claims.get("scope", "").split(),
expires_at=claims["exp"],
resource=RESOURCE,
subject=claims["sub"],
claims=claims,
)
mcp = FastMCP(
"my-mcp-server",
token_verifier=AuthServiceVerifier(),
auth=AuthSettings(
issuer_url=ISSUER,
resource_server_url=RESOURCE,
validate_token_resource=True,
),
)
@mcp.tool()
async def list_documents(page: int = 1) -> str:
"""List documents for the authenticated user."""
access = get_access_token()
assert access is not None and access.claims is not None
claims = access.claims
if "editor" not in claims.get("roles", []):
raise PermissionError("Requires the 'editor' role")
if claims.get("features", {}).get("apiAccess") is not True:
raise PermissionError("API access is not part of your plan")
# Scope every query by claims["sub"] and, when present, claims.get("org_id")
documents = [{"page": page, "owner": claims["sub"], "org_id": claims.get("org_id")}]
return json.dumps(documents)
if __name__ == "__main__":
mcp.run(transport="streamable-http")
This uses the token verifier hooks of the Python MCP SDK (tested with mcp 1.30): FastMCP rejects unauthenticated requests, publishes the protected resource metadata for resource_server_url, and get_access_token() returns the verified token with its claims.
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:
subequal to the application slug (not a user ID) andscopeequal tom2m;audequal to the resource URL;- no
roles,permissionsorfeaturesclaims, 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
- PKCE is mandatory. Every application requires it, and only
S256is accepted. - Validate
issandaud. Pinissto the issuer exactly (no trailing slash) and requireaudto contain your MCP server URL, so a token minted for another application is refused. - Cache the JWKS by
kid. Refetch only when you meet an unknownkid; the libraries above do this. - 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, callPOST /api/auth/oauth2/introspectfrom a confidential client. - Use HTTPS for every OAuth endpoint.
- Keep secrets out of code. Client secrets (confidential applications) belong in environment variables or a secrets manager.
- Request only the scopes you need.