Use case

Add auth to an MCP server

MCP clients expect to discover your authorization server on their own, register, and run an OAuth 2.1 code flow with PKCE. theAuth ships that server and the token checks. Your tool server only decides which scope each tool needs.

The problem

A client you have never met has to log in without help.

A public MCP server cannot hand out API keys by email. The client that connects might be an editor, a desktop assistant, or an agent running somewhere you do not control, and no human is available to paste a secret into a config file.

So the server has to behave like a proper OAuth resource. When a request arrives without a token, the 401 response must say where to look next. The client reads the resource metadata, finds the authorization server, registers itself, sends the user through a code flow, and comes back with a token that only works on this server.

The theAuth path

An authorization server and a validator, both in your process.

You keep your own users and consent screen. theAuth supplies the protocol parts.

Authorization servercreateMcpModule
createMcpModule from @glinr/theauth/mcp implements the authorize, token and register handlers plus both metadata documents. You supply storage callbacks for clients, codes and tokens, and a function that tells it which user is signed in.
PKCES256 only
The authorize handler rejects any code_challenge_method other than S256. The token endpoint checks the verifier against the stored challenge.
Resource metadataRFC 9728, RFC 8414
mcp.getProtectedResourceMetadata() and mcp.getMetadata() return the two documents served at /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server.
Scoped tokens per toolvalidateToken
Access tokens are signed JWTs carrying scope and an audience. mcp.validateToken(token, [scope]) checks signature, expiry, audience and the scope you ask for, and returns INSUFFICIENT_SCOPE when it is missing.
Framework adaptersHono, Next.js, others
The Hono adapter (theAuthHono) mounts the MCP routes for you. Next.js, Express, Fastify and others have adapters too, listed in the adapters docs.
An MCP client starts with only the MCP server URL, discovers the authorization server from metadata, registers, and authorizes with PKCE S256. theAuth issues the token, and the MCP server validates its signature, audience, scope and expiry before it runs the tool.

Step by step

From an empty Hono app to a protected tool.

Excerpts, in order. Replace the in-memory stores and the stub user lookup before you deploy.

  1. Install the packages

    The core, the Hono adapter, and a Node server for Hono. Hono runs elsewhere too, since it uses web-standard Request and Response.

    terminal
    npm install @glinr/theauth @glinr/theauth-hono hono @hono/node-server
  2. Create the auth instance and the MCP module

    issuer is the public origin of your server. baseUrl is where the adapter will serve the MCP routes, here under /api. signingSecret must be at least 32 characters, or createMcpModule throws.

    Pre-registered clients and dynamic registration both go through storeClient. The stores below are in memory so the excerpt stays short.

    src/mcp.ts
    import { createTheAuth } from "@glinr/theauth";
    import { createMcpModule } from "@glinr/theauth/mcp";
    import type { McpAccessToken, McpAuthorizationCode, McpClient } from "@glinr/theauth/mcp";
    
    const BASE_URL = process.env.BASE_URL ?? "http://localhost:3001";
    
    export const auth = await createTheAuth({
      database: { provider: "sqlite", url: "file:./auth.db" },
    });
    
    const clients = new Map<string, McpClient>();
    const codes = new Map<string, McpAuthorizationCode>();
    const tokens = new Map<string, McpAccessToken>();
    const byRefresh = new Map<string, string>();
    
    export const mcp = createMcpModule({
      config: {
        enabled: true,
        issuer: BASE_URL,
        baseUrl: BASE_URL + "/api",
        signingSecret: process.env.MCP_SIGNING_SECRET!, // 32+ characters
        scopes: ["mcp:read", "mcp:execute"],
        accessTokenTtl: 3600,
      },
      storeClient: async (c) => { clients.set(c.clientId, c); },
      findClient: async (id) => clients.get(id) ?? null,
      storeAuthorizationCode: async (c) => { codes.set(c.code, c); },
      consumeAuthorizationCode: async (code) => {
        const found = codes.get(code) ?? null;
        codes.delete(code);
        return found;
      },
      storeToken: async (t) => {
        tokens.set(t.accessToken, t);
        if (t.refreshToken) byRefresh.set(t.refreshToken, t.accessToken);
      },
      findTokenByRefreshToken: async (r) => {
        const at = byRefresh.get(r);
        return at ? tokens.get(at) ?? null : null;
      },
      revokeToken: async (at) => { tokens.delete(at); },
      // Return the signed-in user's id from your own session, or null.
      resolveUserId: async (request) => getUserIdFromSession(request),
    });
  3. Mount the routes and publish discovery at the root

    The adapter serves /mcp/register, /mcp/authorize and /mcp/token, and the two .well-known documents, relative to where you mount it. MCP clients look for the well-known documents at the origin root, so serve them there too.

    Check the result with curl. The response is the resource metadata, abridged here.

    src/server.ts
    import { serve } from "@hono/node-server";
    import { Hono } from "hono";
    import { theAuthHono } from "@glinr/theauth-hono";
    import { auth, mcp } from "./mcp";
    
    const app = new Hono();
    
    // /api/mcp/register, /api/mcp/authorize, /api/mcp/token
    app.route("/api", theAuthHono(auth, { mcp }));
    
    // Same documents at the origin root, where clients look first.
    app.get("/.well-known/oauth-protected-resource", (c) =>
      c.json(mcp.getProtectedResourceMetadata()));
    app.get("/.well-known/oauth-authorization-server", (c) =>
      c.json(mcp.getMetadata()));
    
    serve({ fetch: app.fetch, port: 3001 });
  4. Check discovery

    Metadata documents are plain JSON. If a client cannot connect, this is the first thing to look at.

    terminal
    curl http://localhost:3001/.well-known/oauth-protected-resource
  5. Require a scope per tool

    Map each tool to the narrowest scope that covers it, and validate before you run it. A missing token gets a 401 with the metadata pointer, so the client can start the flow. A valid token without the scope gets a 403.

    validateToken also rejects tokens whose audience does not match, so a token minted for another server fails even with a valid signature.

    src/tools.ts
    const TOOL_SCOPES: Record<string, string> = {
      list_repos: "mcp:read",
      create_comment: "mcp:execute",
    };
    
    app.post("/tools/call/:name", async (c) => {
      const scope = TOOL_SCOPES[c.req.param("name")];
      if (!scope) return c.json({ error: "Unknown tool" }, 404);
    
      const token = c.req.header("authorization")?.replace(/^Bearer /i, "");
      if (!token) {
        return c.json({ error: "Bearer token required" }, 401, {
          "WWW-Authenticate":
            'Bearer resource_metadata="' + BASE_URL + '/.well-known/oauth-protected-resource"',
        });
      }
    
      const result = await mcp.validateToken(token, [scope]);
      if (!result.success) {
        const status = result.error.code === "INSUFFICIENT_SCOPE" ? 403 : 401;
        return c.json({ error: result.error.message }, status);
      }
    
      // result.data.userId, result.data.clientId, result.data.scopes
      return c.json(await runTool(c.req.param("name"), await c.req.json()));
    });
  6. What the client does

    Your users never write this, an MCP client does. It is here so you can test the flow by hand. The authorization request carries the PKCE challenge, the scope, and the resource parameter. The token request carries the verifier.

    Register the client first with a POST to /api/mcp/register. Both endpoints enforce that redirect_uri matches what was registered.

    client.ts
    const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
    const challenge = base64url(
      new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))),
    );
    
    const url = new URL("https://mcp.example.com/api/mcp/authorize");
    url.searchParams.set("response_type", "code");
    url.searchParams.set("client_id", clientId);
    url.searchParams.set("redirect_uri", redirectUri);
    url.searchParams.set("code_challenge", challenge);
    url.searchParams.set("code_challenge_method", "S256");
    url.searchParams.set("scope", "mcp:read mcp:execute");
    url.searchParams.set("resource", "https://mcp.example.com");
    
    // After the redirect back with ?code=...
    const res = await fetch("https://mcp.example.com/api/mcp/token", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        grant_type: "authorization_code",
        code,
        code_verifier: verifier,
        client_id: clientId,
        redirect_uri: redirectUri,
      }),
    });

The snippets are excerpts checked against the package source and types for @glinr/theauth 0.5.0 and @glinr/theauth-hono 3.1.0 on 2026-10-06. They are not a complete app: getUserIdFromSession, runTool and base64url are yours to write. The MCP docs page is the reference for the full option list.

Pitfalls

Where MCP auth setups usually go wrong.

  • Metadata served under a prefix only.The adapter registers .well-known routes relative to its mount point. Mounted at /api, they live at /api/.well-known/.... Clients look at the origin root, so add the root routes as in step 3, or mount the adapter at the root.
  • The adapter mounts more than OAuth.theAuthHono also registers management routes such as agents, delegations and audit. Check what is exposed on a public origin and put your own access control in front of what should not be public.
  • Using the stub stores in production.In-memory maps lose clients and refresh tokens on restart and do not work across instances. Back the callbacks with your database.
  • A short or missing signing secret.createMcpModule throws unless signingSecret is 32 characters or longer. Keep it out of source control.
  • One broad scope for every tool.Validate a specific scope per tool, as in step 5. A single mcp:execute for everything makes a stolen token as powerful as your riskiest tool.
  • Plain PKCE in older clients.The authorize endpoint accepts S256 only and returns an error otherwise. A client that sends plain must be fixed, not accommodated.

Get started

Give your first agent an identity.

Install the package, create an agent with scoped permissions, and read its first audit record. The core runs on Postgres, SQLite, MySQL or D1, and the Go module needs a single go get.

  • npm install @glinr/theauth
  • go get github.com/glincker/theauth-go
Or skip hosting with theAuth Cloud