---
title: "Secure an MCP server with theAuth in 10 minutes"
description: "Put OAuth 2.1 in front of a TypeScript MCP server: dynamic client registration, PKCE S256, scoped tokens and a protected route. Every command was run first."
canonical: https://theauth.dev/guides/mcp-server-typescript/
lastmod: 2026-10-08
---

Guide

# Secure an MCP server with theAuth in 10 minutes

An MCP client discovers your server, registers itself, sends a person through an authorization step with PKCE and comes back with a token your route can check. You will build that flow in one file and then run a client against it.

Run end to end on 2026-10-08 with @glinr/theauth 0.6.0, @glinr/theauth-hono 4.0.0, Hono 4 and Node 22.

## What you will build

- An OAuth 2.1 authorization server with discovery documents, dynamic client registration, PKCE S256 and a token endpoint.
- A protected /mcp/tools route that answers 401 without a token and runs for a token with the mcp:read scope.
- A short client script that performs the whole flow so you can watch each step succeed.

## Before you start

- Node 22 or newer and pnpm. npm works too: swap pnpm add for npm install.
- A terminal. No database to install: this guide uses the built-in SQLite driver (sql.js) and in-memory stores for the OAuth state.

## Create the project

Make a folder and install the packages. sql.js is the SQLite driver theAuth uses by default, and it is a peer dependency, so pnpm will not install it for you.

terminal

```js
mkdir mcp-demo && cd mcp-demo
pnpm init && pnpm pkg set type=module
pnpm add @glinr/theauth @glinr/theauth-hono hono @hono/node-server sql.js
pnpm add -D tsx typescript @types/node
```

Create a signing secret for access tokens. It must be at least 32 characters, and the server refuses to start with a shorter one.

terminal

```js
export MCP_SIGNING_SECRET=$(openssl rand -hex 32)
```

## Write the server

Save this as src/server.ts. Read the comments: three pieces matter. createMcpModule is the authorization server, and you give it storage as plain functions. theAuthHono mounts its endpoints. requireScopes guards your own route.

src/server.ts

```ts
import { serve } from "@hono/node-server";
import { createTheAuth } from "@glinr/theauth";
import { createMcpModule } from "@glinr/theauth/mcp";
import type {
  McpAccessToken,
  McpAuthorizationCode,
  McpClient,
  McpSession,
} from "@glinr/theauth/mcp";
import { theAuthHono } from "@glinr/theauth-hono";
import { Hono } from "hono";

const ORIGIN = "http://localhost:3000";

// In-memory stores. Swap these for your database before you deploy.
const clients = new Map<string, McpClient>();
const codes = new Map<string, McpAuthorizationCode>();
const tokens = new Map<string, McpAccessToken>();
const byRefresh = new Map<string, string>();

const theauth = await createTheAuth({
  database: { provider: "sqlite", url: "theauth.db" },
  baseUrl: ORIGIN,
});

const mcp = createMcpModule({
  config: {
    enabled: true,
    issuer: ORIGIN,
    baseUrl: `${ORIGIN}/api/theauth`,
    signingSecret: process.env.MCP_SIGNING_SECRET ?? "",
    scopes: ["mcp:read", "mcp:execute"],
  },
  storeClient: async (client) => {
    clients.set(client.clientId, client);
  },
  findClient: async (clientId) => clients.get(clientId) ?? null,
  storeAuthorizationCode: async (code) => {
    codes.set(code.code, code);
  },
  consumeAuthorizationCode: async (code) => {
    const found = codes.get(code) ?? null;
    if (found) codes.delete(code);
    return found;
  },
  storeToken: async (token) => {
    tokens.set(token.accessToken, token);
    if (token.refreshToken) byRefresh.set(token.refreshToken, token.accessToken);
  },
  findTokenByRefreshToken: async (refreshToken) => {
    const access = byRefresh.get(refreshToken);
    return access ? (tokens.get(access) ?? null) : null;
  },
  revokeToken: async (accessToken) => {
    tokens.delete(accessToken);
  },
  // Demo only: every request is "signed in" as this user.
  resolveUserId: async () => "demo-user",
});

const app = new Hono<{ Variables: { mcpSession: McpSession } }>();

// The adapter also mounts an agent REST API with no authentication of its own.
// Let only the OAuth endpoints through and answer 404 for the rest.
app.use("/api/theauth/*", async (c, next) => {
  const path = c.req.path;
  const oauthOnly =
    path.startsWith("/api/theauth/mcp/") || path.startsWith("/api/theauth/.well-known/");
  return oauthOnly ? next() : c.json({ error: "not found" }, 404);
});
app.route("/api/theauth", theAuthHono(theauth, { mcp }));

// MCP clients look for discovery documents at the origin root.
app.get("/.well-known/oauth-authorization-server", (c) => c.json(mcp.getMetadata()));
app.get("/.well-known/oauth-protected-resource", (c) => c.json(mcp.getProtectedResourceMetadata()));

app.use("/mcp/*", async (c, next) => {
  const check = await mcp.requireScopes(c.req.raw, ["mcp:read"]);
  if (!check.authorized) return check.response;
  c.set("mcpSession", check.session);
  await next();
});

app.get("/mcp/tools", (c) => {
  const session = c.get("mcpSession");
  return c.json({ user: session.userId, client: session.clientId, tools: ["echo"] });
});

serve({ fetch: app.fetch, port: 3000 });
console.log(`listening on ${ORIGIN}`);
```

Two lines deserve a second look. The use("/api/theauth/*") block is there because the Hono adapter also mounts an agent REST API (create, list and audit agents) with no authentication of its own. This server only needs the OAuth endpoints, so everything else answers 404. And resolveUserId returns a fixed user: it is where your real session lookup goes, and the next guide wires it to a login.

## Start it and read the discovery document

terminal

```ts
pnpm tsx src/server.ts
# in a second terminal:
curl -s http://localhost:3000/.well-known/oauth-authorization-server
```

The metadata lists the authorization, token and registration endpoints and code_challenge_methods_supported: ["S256"]. The adapter serves the same documents under /api/theauth/.well-known/. MCP clients look at the root of your origin, which is why the server file adds the two root routes.

## Run a client against it

Save this as src/flow.ts. It does what an MCP client does: try the route, register, authorize with a PKCE challenge, exchange the code with the verifier, then call the route with the token.

src/flow.ts

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

const ORIGIN = "http://localhost:3000";
const API = `${ORIGIN}/api/theauth`;
const redirectUri = "http://localhost:8976/callback";

// 1. Without a token the MCP route answers 401 with a WWW-Authenticate header.
const denied = await fetch(`${ORIGIN}/mcp/tools`);
console.log("no token:", denied.status, denied.headers.get("www-authenticate"));

// 2. Register a public client (RFC 7591).
const reg = await fetch(`${API}/mcp/register`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    client_name: "flow-script",
    redirect_uris: [redirectUri],
    grant_types: ["authorization_code"],
    token_endpoint_auth_method: "none",
  }),
});
const client = (await reg.json()) as { client_id: string };

// 3. Authorize with PKCE S256. The server answers with a redirect carrying the code.
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
const authUrl = new URL(`${API}/mcp/authorize`);
authUrl.search = new URLSearchParams({
  response_type: "code",
  client_id: client.client_id,
  redirect_uri: redirectUri,
  scope: "mcp:read",
  code_challenge: challenge,
  code_challenge_method: "S256",
  resource: ORIGIN,
}).toString();
const authRes = await fetch(authUrl, { redirect: "manual" });
const code = new URL(authRes.headers.get("location") ?? "").searchParams.get("code");
if (!code) throw new Error(`no code, status ${authRes.status}`);

// 4. Exchange the code with the verifier.
const tokenRes = await fetch(`${API}/mcp/token`, {
  method: "POST",
  headers: { "content-type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code,
    redirect_uri: redirectUri,
    client_id: client.client_id,
    code_verifier: verifier,
    resource: ORIGIN,
  }),
});
const token = (await tokenRes.json()) as { access_token: string };

// 5. Call the protected route.
const ok = await fetch(`${ORIGIN}/mcp/tools`, {
  headers: { authorization: `Bearer ${token.access_token}` },
});
console.log("with token:", ok.status, await ok.json());
```

terminal

```json
pnpm tsx src/flow.ts
```

Expected output (the client id differs):

output

```ts
no token: 401 Bearer resource_metadata="http://localhost:3000/api/theauth/.well-known/oauth-protected-resource"
with token: 200 {
  user: 'demo-user',
  client: 'fb67f6fa-970a-4046-a5c5-98bf91c37262',
  tools: [ 'echo' ]
}
```

The 401 carries a WWW-Authenticate header that points at the protected resource metadata. That pointer is how a client that has never seen your server finds out where to sign in.

## Point a real MCP client at it

Give the client your MCP endpoint URL. A spec-compliant client reads the 401 header, fetches the metadata, registers through /api/theauth/mcp/register, and sends the person to /api/theauth/mcp/authorize. Because resolveUserId is a stub, the person is signed in as demo-user without a prompt. Replace that function before anyone else can reach the server.

## Before you ship this

- **Replace resolveUserId.** Return the signed-in user from your session, or null to send the person to loginPage. The [Hono guide](https://theauth.dev/guides/hono-human-and-agent-auth/) shows a real login.
- **Move the OAuth state to a database.** The Maps lose clients, codes and tokens on restart. Keep the single-use rule on consumeAuthorizationCode.
- **Serve over HTTPS** and set ORIGIN to the public URL. The issuer appears in every token.
- **Keep the signing secret out of the repository** and rotate it deliberately: tokens signed with the old one stop validating.
- **Check the audience.** validateToken confirms an audience claim exists but does not compare it with your resource URL. See [the MCP docs](https://docs.theauth.dev/mcp) for expectedAudience.

## Questions

**Do I need a database?**

Not for this guide. The OAuth state is in memory and theAuth keeps its own tables in SQLite through sql.js. For production, back the store functions with your database.

**Why does the adapter need a guard?**

The Hono adapter mounts the agent REST API as well as the OAuth endpoints, and those routes do not authenticate callers. The guard in the server file lets only the MCP OAuth paths through.

**Does this work with Go?**

Yes. The Go library has its own authorization server and a resource-server module. See the Go guide below.

## Keep going

[Add human login plus agent auth to Hono](https://theauth.dev/guides/hono-human-and-agent-auth/) [The same in Go](https://theauth.dev/guides/mcp-authorization-server-go/) [MCP OAuth docs](https://docs.theauth.dev/mcp) [Use case: MCP servers](https://theauth.dev/use-cases/mcp-servers/)
