Secure an MCP Server for Production with theAuth
theAuth is open-source auth for AI agents and humans. Star the repo on GitHub · Read the docs · Run...
theAuth is open-source auth for AI agents and humans. Star the repo on GitHub · Read the docs · Run the quickstart · theauth.dev
Most MCP tutorials stop the moment the demo works. A tool server answers tools/list, the client shows a green dot, and everyone goes home. Then someone puts that server behind a public URL.
I want to walk through what comes after the green dot. Not the OAuth theory, which has plenty of good write-ups. This guide covers the production layers around it: tokens that expire fast, scopes tied to individual tools, argument checks, rate limits, and an audit trail you can query. I will use theAuth, an open-source auth library for AI agents and humans, because I have read its source and docs closely enough to tell you where it stops. Four of those stopping points matter, so I flag each one.
By the end you will have a Hono-based MCP endpoint with six protections, plus a checklist you can paste into a pull request template.
This is guide 7 of 8 in the theAuth guides. It stands on its own, so you can start right here. Guide 5 gives background on agent identities if you want it.
| Guide | Title | Read it when |
|---|---|---|
| 1 | Add login to an existing Next.js app | You have an app with no auth yet |
| 2 | Passwordless login: passkeys, links, OTP | You want to drop passwords or add 2FA |
| 3 | Multi-tenant SaaS auth: orgs, RBAC, SSO, SCIM | You sell to teams and companies |
| 4 | Migrate from Auth0 or Clerk | You already run another provider |
| 5 | Give every AI agent its own identity | You run AI agents and need to start somewhere |
| 6 | Cap agent spend and require human approval | Your agents spend money or act on risky things |
| 7 | Secure an MCP server for production (this guide) | You expose tools over MCP |
| 8 | Build an audit trail for AI agent actions | Someone will ask what your agents did |
Building for people? Start at guide 1. Building for AI agents? Start at guide 5. Every guide links to the docs page for each concept it touches.
TL;DR#
| Layer | What it does | theAuth piece | Ships today |
|---|---|---|---|
| Identity | OAuth 2.1 with PKCE S256, short-lived tokens | createMcpModule |
Yes |
| Scopes | Each tool needs a named scope | mcp.requireScopes |
Yes |
| Tool policy | Argument patterns, approval gates, call caps | theauth.authorize |
Yes |
| Rate limits | Per-IP on auth routes, per-user on tool calls | createRateLimiter |
Yes, in memory |
| Audit | Allow and deny decisions, queryable | theauth.audit |
Yes |
| Edge proxy | Auth in front of a server you cannot edit | @glinr/theauth-gateway |
Yes, agent tokens only |
Budget a working session if your database and login page already exist.
Prerequisites#
You need Node.js with ESM and top-level await, a Postgres or SQLite database, and a login page that can tell you who the signed-in user is. You also need a 32 character secret for token signing.
I assume you already have an MCP server that speaks JSON-RPC over HTTP. I will refer to its transport handler as handleMcp. Swap in whatever your MCP SDK gives you.
Install the packages:
pnpm add @glinr/theauth @glinr/theauth-hono hono @hono/node-serverIf you have never touched theAuth, the quickstart covers the database setup in about six steps. This guide starts after that.
Two kinds of token, and why it matters#
Before any code, one distinction saves a day of confusion.
theAuth issues two different bearer credentials. The first is an OAuth access token that the MCP module mints. This HS256 JWT carries a user ID, a client ID, an audience, and scopes. MCP clients like desktop assistants get this one after the user approves access.
The second is an agent token, prefixed kv_. You create it with theauth.agent.create, theAuth stores only its hash, and it maps to a permission list. Scripts and service accounts use it. The agent identity page covers its lifecycle.
Different code checks each one. mcp.requireScopes handles OAuth tokens. The permission engine and the gateway handle agent tokens. I checked the gateway source: it calls theauth.agent.validateToken, so it does not accept OAuth JWTs. Keep that in mind when you reach the proxy section.
Here is the plan. OAuth tokens prove which human approved which client. A service agent holds the permission rules for your tools. Both feed the same audit log.
Step 1: Build the storage callbacks#
The MCP module has no database of its own. You hand it callbacks for clients, codes, and tokens. The MCP OAuth page lists them all.
Here is the shape, with in-memory maps so the code runs anywhere. Replace the maps with table reads before you ship.
// lib/mcp-store.ts
import type { McpAccessToken, McpAuthorizationCode, McpClient } from '@glinr/theauth/mcp';
const clients = new Map<string, McpClient>();
const codes = new Map<string, McpAuthorizationCode>();
const tokens = new Map<string, McpAccessToken>();
const byRefreshToken = new Map<string, string>();
export const mcpStore = {
storeClient: async (client: McpClient) => {
clients.set(client.clientId, client);
},
findClient: async (clientId: string) => clients.get(clientId) ?? null,
storeAuthorizationCode: async (code: McpAuthorizationCode) => {
codes.set(code.code, code);
},
consumeAuthorizationCode: async (code: string) => {
const found = codes.get(code) ?? null;
if (found) codes.delete(code); // single use, always
return found;
},
storeToken: async (token: McpAccessToken) => {
tokens.set(token.accessToken, token);
if (token.refreshToken) byRefreshToken.set(token.refreshToken, token.accessToken);
},
findTokenByRefreshToken: async (refreshToken: string) => {
const access = byRefreshToken.get(refreshToken);
return access ? (tokens.get(access) ?? null) : null;
},
revokeToken: async (accessToken: string) => {
tokens.delete(accessToken);
},
resolveUserId: async (request: Request): Promise<string | null> => {
// Read your own session cookie here. Return null to send the user to loginPage.
void request;
return null;
},
};Two lines in that file carry the weight. consumeAuthorizationCode must delete the code on read. If it does not, a stolen code works twice. And resolveUserId is the bridge to your login system. Get it wrong and the whole flow authorizes the wrong person.
Step 2: Configure the module for production#
Now create the theAuth instance and the MCP module. The defaults are friendly to demos. I change four of them.
// lib/theauth.ts
import { createTheAuth } from '@glinr/theauth';
import { createMcpModule } from '@glinr/theauth/mcp';
import { mcpStore } from './mcp-store.js';
export const MCP_RESOURCE = process.env.MCP_RESOURCE_URL!; // your public MCP origin
const AUTH_BASE_URL = process.env.AUTH_BASE_URL!; // your public auth origin
export const theauth = await createTheAuth({
database: { provider: 'postgres', url: process.env.DATABASE_URL! },
baseUrl: AUTH_BASE_URL,
mcp: { enabled: true }, // creates the registry tables only
agents: { enabled: true },
hooks: {
async onViolation({ type, agentId, action, resource, reason }) {
try {
await sendAlert({ type, agentId, action, resource, reason });
} catch (error) {
console.error('[onViolation] alert failed', error);
}
},
},
});
export const mcp = createMcpModule({
config: {
enabled: true,
issuer: AUTH_BASE_URL,
baseUrl: `${AUTH_BASE_URL}/api/theauth`,
signingSecret: process.env.MCP_SIGNING_SECRET!, // 32 characters or more
scopes: ['mcp:read', 'mcp:write', 'mcp:execute'],
accessTokenTtl: 900, // 15 minutes instead of 3600
refreshTokenTtl: 86_400, // 1 day instead of 7
codeTtl: 120, // 2 minutes instead of 600
allowedResources: [MCP_RESOURCE],
loginPage: `${AUTH_BASE_URL}/login`,
consentPage: `${AUTH_BASE_URL}/consent`,
},
...mcpStore,
});Why these four?
The access token TTL is the big one. Look at the validation path in the source: it checks signature, issuer, expiry, and scopes. It does not look the token up in your store. A revoked access token keeps working until exp. The TTL becomes your revocation window, and I can defend 15 minutes to a security reviewer. Pick your own, but pick it on purpose.
The code TTL goes down because an authorization code should live for seconds in a healthy flow. Two minutes is generous.
allowedResources pins the audience. With the list set, a client that asks for a resource outside it gets rejected. Without it, any client can ask for a token bound to anything.
consentPage matters because registration is open by design. The MCP spec uses dynamic client registration (RFC 7591), so any client can register itself at /mcp/register. With consentPage configured, the authorization endpoint redirects there instead of issuing a code. Your page then shows the user the client name and scopes, and calls mcp.approveConsent(...) once they agree. Without a consent page, a logged-in user visiting a hostile link gets a code issued with no prompt, as far as I can tell from the docs.
The hooks page explains onViolation. The warning there is worth repeating: that hook is fire and forget, so a throw becomes an unhandled rejection. The try/catch above is not decoration.
Step 3: Mount the routes, including the well-known ones#
The Hono adapter mounts everything under a prefix. Pass the module in and it registers the registration, authorization, and token routes. The Hono adapter page has the full list.
// src/index.ts
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { theAuthHono } from '@glinr/theauth-hono';
import { createRateLimiter } from '@glinr/theauth/auth';
import { mcp, theauth } from './lib/theauth.js';
const app = new Hono();
// Throttle the OAuth endpoints by IP. See the rate limit section for why.
const authLimit = createRateLimiter({ max: 30, window: 60 });
app.use('/api/theauth/mcp/*', async (c, next) => {
const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
const result = authLimit.check(ip);
if (!result.allowed) {
return c.json({ error: { code: 'RATE_LIMITED', message: 'Too many requests' } }, 429, {
'Retry-After': '60',
});
}
await next();
});
app.route('/api/theauth', theAuthHono(theauth, { mcp }));
// MCP clients look for metadata at the origin root, not under the mount prefix.
app.get('/.well-known/oauth-authorization-server', (c) => c.json(mcp.getMetadata()));
app.get('/.well-known/oauth-protected-resource', (c) => c.json(mcp.getProtectedResourceMetadata()));The last two routes trip up almost everyone. The adapter registers the metadata documents relative to its mount point, so they live at /api/theauth/.well-known/.... A client that probes /.well-known/oauth-protected-resource at your origin root gets a 404 and gives up. Add the root routes yourself.
The order also matters. In Hono, the middleware must come before the app.route call it should wrap.
Check it from a terminal:
curl -s "$MCP_RESOURCE_URL/.well-known/oauth-protected-resource"You should see your issuer in authorization_servers and your scopes in scopes_supported. If the response is empty or a 404, fix that before going further.
Step 4: Require a scope per tool#
Here is where most guides give you one requireScopes(['mcp:read']) on the whole /mcp route. That treats read_file and run_job the same. They are not the same.
An MCP server on streamable HTTP usually has one endpoint, and the tool name sits inside the JSON-RPC body. You read the body, find the tool, and map it to a scope.
// lib/tool-scopes.ts
export const TOOL_SCOPES: Record<string, string> = {
list_files: 'mcp:read',
read_file: 'mcp:read',
write_file: 'mcp:write',
run_job: 'mcp:execute',
};
export interface RpcMessage {
jsonrpc: '2.0';
id?: string | number;
method: string;
params?: { name?: string; arguments?: Record<string, unknown> };
}
/** Scopes a message needs. Returns null for a tool call we do not know. */
export function scopesFor(message: RpcMessage): string[] | null {
if (message.method !== 'tools/call') return []; // initialize, tools/list, ping
const name = message.params?.name;
const scope = name ? TOOL_SCOPES[name] : undefined;
return scope ? [scope] : null;
}Unknown tools return null, and the route rejects them. That is deny by default. A tool added next month has no scope until you give it one, which is the behavior you want.
Now the route. It does five jobs in order: parse, check scopes, check the audience, check the per-user limit, then forward.
// src/mcp-route.ts
import type { Context } from 'hono';
import { createRateLimiter } from '@glinr/theauth/auth';
import { MCP_RESOURCE, mcp } from './lib/theauth.js';
import { scopesFor, type RpcMessage } from './lib/tool-scopes.js';
import { authorizeTool } from './lib/tool-policy.js';
import { handleMcp } from './lib/mcp-server.js'; // your MCP transport
const userLimit = createRateLimiter({ max: 120, window: 60 });
const revokedTokenIds = new Set<string>(); // back this with your database
export async function mcpRoute(c: Context): Promise<Response> {
const text = await c.req.text();
let parsed: RpcMessage | RpcMessage[];
try {
parsed = JSON.parse(text) as RpcMessage | RpcMessage[];
} catch {
return c.json({ error: 'invalid_json' }, 400);
}
const messages = Array.isArray(parsed) ? parsed : [parsed];
const needed = new Set<string>();
for (const message of messages) {
const scopes = scopesFor(message);
if (scopes === null) return c.json({ error: 'unknown_tool' }, 403);
scopes.forEach((scope) => needed.add(scope));
}
const check = await mcp.requireScopes(c.req.raw, [...needed]);
if (!check.authorized) return check.response;
const session = check.session;
// The module only checks that an audience exists. Compare it yourself.
if (session.resource !== MCP_RESOURCE) {
return c.json({ error: 'invalid_audience' }, 401);
}
if (revokedTokenIds.has(session.tokenId)) {
return c.json({ error: 'token_revoked' }, 401);
}
const limit = userLimit.check(session.userId);
if (!limit.allowed) {
const wait = Math.max(1, Math.ceil((limit.resetAt.getTime() - Date.now()) / 1000));
return c.json({ error: 'rate_limited' }, 429, { 'Retry-After': String(wait) });
}
const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim();
for (const message of messages) {
if (message.method !== 'tools/call' || !message.params?.name) continue;
const decision = await authorizeTool(message.params.name, message.params.arguments ?? {}, ip);
if (!decision.allowed) {
return c.json({ error: 'tool_denied', reason: decision.reason }, 403);
}
}
const forwarded = new Request(c.req.url, { method: 'POST', headers: c.req.raw.headers, body: text });
return handleMcp(forwarded);
}Register it with app.post('/mcp', mcpRoute) in src/index.ts.
Three details in that route deserve a closer look.
mcp.requireScopes returns a ready-made response. A missing or bad token gets a 401 with a WWW-Authenticate header that points at your resource metadata. A valid token without the scope gets a 403 with an insufficient_scope challenge and an upgrade URL. Well-behaved clients use that to ask the user for more access. You do not build either response by hand.
The audience comparison is the second detail. The docs are blunt about it: validateToken confirms an audience claim exists and does not compare it with your resource URL. A token minted for a different resource, signed by the same secret, would pass. My two-line check closes that gap. The allowedResources setting from Step 2 limits what clients can request, and the comparison here limits what you accept.
The third is session.tokenId. The session comes from the JWT jti claim. Hold a deny list of those IDs if you need to cut off a token before it expires. It costs one set lookup per call, and it covers the gap the short TTL leaves.
Step 5: Add tool policy with the permission engine#
Scopes answer "may this client call write tools at all?" They cannot answer "may it write to this path?" or "is this the 500th call this hour?" The permission engine handles those.
The engine works on agents, so create one service agent that represents your MCP server. Run this once at deploy time, not per request.
// scripts/create-service-agent.ts
import { theauth } from '../src/lib/theauth.js';
const agent = await theauth.agent.create({
ownerId: process.env.SERVICE_OWNER_USER_ID!, // must be an existing user row
name: 'files-mcp-server',
type: 'service',
expiresAt: new Date(Date.now() + 90 * 24 * 3_600_000), // the default is 24 hours
permissions: [
{ resource: 'mcp:files:list_files', actions: ['execute'] },
{
resource: 'mcp:files:read_file',
actions: ['execute'],
constraints: { maxCallsPerHour: 600 },
},
{
resource: 'mcp:files:write_file',
actions: ['execute'],
constraints: { allowedArgPatterns: ['^/srv/data/'], maxCallsPerHour: 120 },
},
{
resource: 'mcp:files:run_job',
actions: ['execute'],
constraints: { requireApproval: true },
},
],
});
console.error(`Store this agent ID in FILES_MCP_AGENT_ID: ${agent.id}`);Then the helper the route calls:
// lib/tool-policy.ts
import path from 'node:path';
import { theauth } from './theauth.js';
const AGENT_ID = process.env.FILES_MCP_AGENT_ID!;
export async function authorizeTool(
tool: string,
args: Record<string, unknown>,
ip: string | undefined,
) {
// Only pass the arguments the patterns should judge. Free text such as
// file contents would fail a path regex.
const checked: Record<string, unknown> = {};
if (typeof args.path === 'string') {
checked.path = path.posix.normalize(args.path);
}
return theauth.authorize(AGENT_ID, {
action: 'execute',
resource: `mcp:files:${tool}`,
arguments: Object.keys(checked).length > 0 ? checked : undefined,
ip,
});
}Now the gotchas, because each one cost me a re-read of the docs.
allowedArgPatterns checks every string value in arguments against every pattern. If you pass content along with path, the content has to match ^/srv/data/ too, and the call fails. That is why the helper forwards only path.
A regex is not a path jail. /srv/data/../../etc/passwd matches ^/srv/data/. The path.posix.normalize call collapses the dots first, so the string the pattern sees is /etc/passwd, which fails. Normalize, then match. If your tools take file paths, also resolve symlinks inside the tool itself. The permission engine sees strings, not the filesystem.
requireApproval: true always denies. The reason text is This action requires human approval before execution, and theAuth ships no approval UI. Your code must show a human the pending call and re-run the check after they agree. The approval flows page describes the pattern. Until you build that, run_job is effectively disabled, which is a safe default.
maxCallsPerHour counts per agent and per concrete resource. Because every user calls through one service agent, all users share that cap. It protects the backend. It does not stop one user from eating everyone's budget, which is why the route also has the per-user limiter.
The engine evaluates only the first matching permission. Order your list from specific to general.
Step 6: Rate limits at three layers#
The rate limiting page lists three mechanisms, and it opens with a warning: no single switch exists. I count the layers like this.
The first layer is per IP, on the OAuth endpoints. You saw it in Step 3. I used createRateLimiter rather than the rateLimit() plugin on purpose. The plugin matches paths containing /auth/, such as /auth/sign-in. The MCP routes live under /mcp/*, so I would not assume the plugin covers the token endpoint. Test it against your own mount path before you rely on it.
The second layer is per user on tool calls. That limit sits in the route as userLimit. It keys on session.userId, so a single account cannot flood the server from ten machines.
The third layer is per tool and per resource, from maxCallsPerHour in the permission list. It uses a rolling hour in 5 minute buckets, stored in the database, so every instance shares it.
Layers one and two are in memory. Run three server instances and each keeps its own counter, so your effective limit is three times what you configured. The docs say there is no built-in Redis store. If you scale out, either write your own store or pin rate limiting to a single proxy in front. Know which one you picked.
One more caution. The IP key reads x-forwarded-for. That header is only trustworthy behind a proxy that sets it. Exposed directly, any client can write whatever it likes there and get a fresh bucket per request.
Step 7: Read the audit trail#
Every theauth.authorize() call writes one entry, allowed or denied. The audit page has the full field list. Three queries cover most of what I want from it.
Which tools got denied today:
const startOfDay = new Date(new Date().setHours(0, 0, 0, 0));
const denied = await theauth.audit.query({ result: 'denied', since: startOfDay });
const byResource = denied.reduce<Record<string, number>>((acc, entry) => {
acc[entry.resource] = (acc[entry.resource] ?? 0) + 1;
return acc;
}, {});Whether the server is in a runaway loop:
const lastHour = await theauth.audit.query({
agentId: process.env.FILES_MCP_AGENT_ID!,
since: new Date(Date.now() - 3_600_000),
});And a monthly export for whoever asks for evidence:
const csv = await theauth.audit.export({
format: 'csv',
since: new Date('2026-09-01'),
until: new Date('2026-10-01'),
});Three limits to keep in mind. Exports cap at the 10,000 most recent entries, so page through audit.query for larger ranges. The CSV omits the parameters column; only JSON has it. And retention is on you: theauth.audit.cleanup({ retentionDays }) deletes old rows, and nothing runs it for you.
Now the gap I promised. Because all tool calls run under one service agent, each audit entry names that agent and its owner, not the human who approved the OAuth client. The entry has no field for the end user. Log session.userId, session.clientId, and the returned auditId together in your own structured log at the call site. The auditId joins the two records later.
Also note what is not logged. Requests rejected before the permission engine runs, such as a bad OAuth token or an unknown scope, never reach authorize(). Those 401 and 403 responses come from your route, so count them in your own metrics.
The compliance page maps these records to frameworks like SOC 2 and the EU AI Act. The audit page says plainly that theAuth does not make you compliant on its own. Tamper-evidence needs protection at the database level. I would take that sentence seriously.
Step 8: Put the gateway in front, where it fits#
Sometimes you do not own the tool server. It might be a third-party binary, or an internal service nobody wants to touch. The gateway is a reverse proxy for exactly that case.
npx @glinr/theauth-gateway \
--upstream "$UPSTREAM_URL" \
--port 4000 \
--database ./theauth.db \
--config gateway.json \
--strip-authThe --database flag is not optional in practice. The default is :memory:, an empty database, so no token can pass and every request returns 401. Point it at the SQLite file your app uses. The CLI supports SQLite only. For Postgres or MySQL, use embedded mode with createGateway.
A policy file for a server that exposes one path per tool:
{
"audit": true,
"rateLimit": { "windowMs": 60000, "max": 300 },
"policies": [
{
"path": "/tools/write-file",
"method": "POST",
"requiredPermissions": [{ "resource": "filesystem", "actions": ["write"] }],
"rateLimit": { "windowMs": 60000, "max": 20 }
},
{
"path": "/tools/**",
"requiredPermissions": [{ "resource": "mcp", "actions": ["call"] }]
}
]
}Policies match in order and the first match wins. Put the specific path above the /tools/** catch-all, or the catch-all swallows it.
Be honest about where this stops. Three limits apply, and the first decides whether the gateway fits you at all.
First, policies match on path and HTTP method, not on the JSON-RPC body. If your MCP server uses one /mcp endpoint for every tool, the gateway cannot tell read_file from run_job. For that shape, the in-app checks from Steps 4 and 5 are your tool policy. The gateway still adds token validation and a coarse rate limit.
Second, as covered earlier, it validates agent tokens, not OAuth JWTs from the MCP module. A desktop assistant holding an OAuth token will get a 401 from the gateway. The gateway fits scripts and service agents with kv_ tokens.
Third, its rate limits live in memory, per instance, just like the layers above.
Its audit entries also look different from what you might expect. The gateway has no writer of its own. It calls authorizeByToken with the lowercased method as the action and gateway:<path> as the resource. The recorded result reflects whether the agent holds a matching permission, not whether the gateway let the request through.
Step 9 (optional): Emit agent claims on MCP tokens#
If a downstream service wants to know which agent identity sits behind a token, theAuth can embed claims from the IETF agentic JWT draft. You turn it on in the module config with emitAgenticJwtClaims: true and supply getAgenticContext.
Read the standards page before you promise anything to a partner. Only three claims are ever written: agent_id, agent_type, and trust_tier, and only from the values you return. The names on_behalf_of, act, may_act, audit_ref, tool_constraints, wit, and operation exist as constants, but no issuer emits them today. Delegation chains and workload binding are on the roadmap, not in the box.
The hardening checklist#
Copy this into your runbook. Each row maps to a step above.
| Check | Setting or code | Why |
|---|---|---|
| Keep access tokens at 15 minutes or less | accessTokenTtl: 900 |
Checks skip the store, so TTL sets the revocation window |
| Keep codes at 2 minutes or less | codeTtl: 120 |
Shrinks the theft window |
| Make codes single use | Delete inside consumeAuthorizationCode |
A replayed code must fail |
| Pin resources | allowedResources |
Clients cannot request arbitrary audiences |
| Compare the audience | session.resource === MCP_RESOURCE |
The module only checks that one exists |
| Turn the consent screen on | consentPage |
Open registration plus no prompt opens a phishing path |
| Use a strong signing secret | 32 characters or more, from a secret manager | HS256 uses a shared secret |
| Serve well-known routes at the root | Two app.get routes |
Clients probe the origin root |
| Deny unknown tools | scopesFor returns null |
New tools start with no access |
| Give each tool one scope | TOOL_SCOPES map |
Read access should not imply execute |
| Normalize path arguments | path.posix.normalize before regex |
Dot segments defeat prefix patterns |
| Gate dangerous tools on approval | requireApproval: true |
A human sits in the loop |
| Rate limit auth routes by IP | createRateLimiter |
Registration and token endpoints face the public |
| Rate limit tool calls per user | userLimit keyed by userId |
One account cannot drain a shared budget |
| Plan for scale-out | Custom store or single proxy | Memory counters live per process |
| Page a human on denials | onViolation with try/catch |
A throwing hook can crash the process |
Log the end user next to auditId |
Your own structured log | The audit row names the service agent |
| Schedule retention | audit.cleanup in a cron job |
Nothing deletes rows for you |
| Plan the service agent expiry | Set expiresAt, then rotate |
The default lifetime is 24 hours |
Troubleshooting and gotchas#
Every call returns 401 after login works#
Check three things in order. First, the token's iss must equal the issuer in your config, character for character, including a trailing slash. Second, the signingSecret must be identical on every instance that validates tokens. Third, confirm the token has an audience. A client that never sent a resource parameter gets the issuer as its audience, and then your MCP_RESOURCE comparison rejects it.
The client says it cannot find the authorization server#
That is the well-known routes under the prefix problem from Step 3. Fetch /.well-known/oauth-protected-resource at your origin root. If you get a 404, add the two root routes.
The metadata lists endpoints that return 404#
The authorization server metadata advertises revocation_endpoint and jwks_uri. The TypeScript adapters do not serve either one today. Two consequences follow. Clients cannot revoke a token through a standard endpoint, and a third party cannot verify your tokens from a JWKS document. Tokens use HS256, so any service that checks them needs the shared secret. Plan for that before you hand the secret to another team, and rotate it knowing that rotation invalidates every outstanding token.
Refresh token theft goes unnoticed#
Each refresh call revokes the old access token and issues a new pair. The module does not detect reuse of an old refresh token. That logic lives in your findTokenByRefreshToken and revokeToken callbacks. If you want reuse detection, record which refresh tokens you already consumed, and revoke the whole family when one comes back.
The service agent stops working after a day#
You left expiresAt at the default of 24 hours. Token validation then flips the agent to expired. Set an explicit expiry, rotate on a schedule with theauth.agent.rotate, and alert before the date.
The docs link for MCP returns an error#
A small note from my own link checking. At the time of writing, the bare /mcp path on the docs host answers a plain GET with a 405, while the same path with a .md suffix returns the page as markdown. I suspect the docs host reserves /mcp for its own MCP endpoint. I linked the .md form above so the links in this guide resolve.
Go deeper#
The pages I leaned on, in the order I would read them next.
- MCP OAuth 2.1 for the full config reference and the metadata documents
- Permission engine for every constraint and the matching rules
- Rate limiting for the plugin, the limiter, and the permission cap
- Audit trail for filters, exports, and retention
- Gateway for policies, embedded mode, and Docker sidecars
- Standards alignment for what the agentic claims do and do not cover
- Lifecycle hooks for
beforeAuthorizeandonViolation - Approval flows for building the human step
- Compliance mapping for audit evidence
FAQ#
Do I need OAuth for a local MCP server?#
No. A server that runs on the same machine as the client over stdio has no network surface to protect, and OAuth adds friction for no gain. OAuth matters once the server listens on a network address that other people can reach. If you only ever run locally, skip this guide.
Can I use the gateway instead of writing the scope checks?#
Only if your tools sit on separate paths and your callers hold agent tokens. The gateway matches on path and method, and it validates kv_ tokens, not OAuth JWTs from the MCP module. For a single-endpoint MCP server with desktop clients, write the checks in your route.
How do I revoke a token right now?#
The module serves no revocation endpoint today, and validation does not consult the store. Keep a deny list of token IDs (jti) in your route, as in Step 4. Pair it with a short access token lifetime so the list stays small. For agent tokens, theauth.agent.revoke takes effect immediately and is permanent.
What does the audit log not capture?#
It records calls that reach theauth.authorize() or authorizeByToken(). Requests that fail earlier, such as a malformed bearer token or an unknown agent, are not logged by the permission engine. The built-in engine also writes rate limit denials as denied, not rate_limited. Add your own metrics for the early rejections.
Is theAuth the right choice for this?#
It fits when you want the OAuth server, permission rules, and audit trail in one TypeScript codebase that you host. It fits less well if you need a JWKS endpoint, asymmetric signing, or a built-in distributed rate limiter, since none of those ship today. An identity provider you already pay for may serve you better if it covers MCP's flow and you only need scopes. Weigh the gaps in this guide against your own needs.
What I would do first#
If you do only three things from this list, shorten the token TTL, deny unknown tools, and compare the audience. Each one is a few lines of code, and together they close the three holes I would worry about most.
Which of these layers is missing from the MCP server you run today, and what has stopped you from adding it?
Try it yourself#
The fastest way in is the quickstart. If this guide saved you time, a star on GitHub helps other developers find the project, and the docs cover every option used above. More about the project lives at theauth.dev.
Next: guide 8, the audit trail. The full list sits in the table at the top of this page.
GDS K S · thegdsks.com · building Glincker · follow on X @thegdsks
Tokens prove who is calling. Scopes, limits, and logs decide what happens next.