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-server If 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 Source:https://dev.to/thegdsks/secure-an-mcp-server-for-production-with-theauth-1l2bRelated posts
SOLID Principles Explained: 5 Essential Principles for Writing Clean, Maintainable Code
How to Build a Professional Resume in 2026: Best Resume Formats, Templates, and ATS Tips
How to Explain Your Project in a Technical Interview
Stop Calling It "Refactoring" When You Are Just Moving The Bugs Around
PostgreSQL migrations that lock a busy table
