New Ory Agent Security is now live! Claim your complimentary test drive. Get Started!

Skip to main content

Add browser login to your MCP server

In this example we run a ticket server with browser login. For an introduction to its tools, see Build an MCP server with TypeScript. A support agent signs in through Claude Code or Codex and chooses whether the client can read tickets, close them, or both.

To run Ory Hydra Authorization on your own infrastructure, follow the OEL guide or open-source guide.

Ory Network hosts the login and consent pages, and Ory Hydra Authorization issues the access token that the client sends with each MCP request. Your server checks the token and its granted permissions, called scopes, before running a tool. First, grant read access and confirm that closing a ticket fails. Then grant write access and close it.

If your application already has user accounts, you can connect Ory Hydra Authorization to your existing login instead of using the hosted pages.

Prerequisites​

Install the Ory CLI, curl, and jq.

Use a terminal window or tab for setup and running the server. The steps below call this the server terminal.

Get the example​

Install Git, Node.js 22.19 or later, and npm. Use a terminal running Bash or Zsh for setup and running the server.

Clone the examples once. If you already cloned them for another MCP guide, stop its server, remove its client connection, and return to the ory-docs repository root instead of cloning again:

git clone --depth 1 --filter=blob:none --sparse --branch master \
https://github.com/ory/docs.git ory-docs
cd ory-docs
git sparse-checkout set code-examples/mcp-auth

Open the complete example and select its directory. The commands also create a private .env file for this example's settings:

cd code-examples/mcp-auth/typescript/oauth
umask 077
touch .env

Keep this directory open in your server terminal. Run the remaining commands here; each example has its own configuration.

Install this example's dependencies:

npm install

1. Configure Ory Network​

Save your project settings in .env. The server loads this file whenever it starts. The setup commands below create a project and save its settings with this structure:

.env (example)

ORY_PROJECT_ID="<project ID>"
ORY_SDK_URL="https://<project slug>.projects.oryapis.com"
Create the project and save its settings once

In the server terminal, run ory auth to sign in with the Ory CLI, then create a development project. Select your workspace when prompted. The commands save the project ID and URL in .env. The example's .gitignore already excludes this file:

ory auth
ory create project --name "Ticket MCP tutorial" --environment dev --use-project --format json > project.json
printf 'ORY_PROJECT_ID="%s"\nORY_SDK_URL="https://%s.projects.oryapis.com"\n' \
"$(jq -er '.id' project.json)" "$(jq -er '.slug' project.json)" > .env
. ./.env

Configure the project's OAuth service so clients can register automatically and request access tokens for your MCP server:

. ./.env
ory patch oauth2-config --project "$ORY_PROJECT_ID" \
--add '/feature_flags/resource_indicators_enabled=true' \
--add '/oidc/dynamic_client_registration={"enabled":true,"default_audience":["http://127.0.0.1:18480/mcp"],"default_scope":["tickets:read","tickets:write","offline_access"]}' \
--add "/webfinger/oidc_discovery/client_registration_url=\"$ORY_SDK_URL/oauth2/register\"" \
--add '/strategies/access_token="jwt"' --format json > oauth-config.json

The configuration controls registration and access tokens:

  • Each MCP client registers automatically with your project when a user first signs in. This is called dynamic client registration (DCR). The project publishes the registration URL so clients can find it.
  • The resource URL identifies the server that accepts a token. New registrations receive this URL as their default audience, and clients can request it using resource indicators, defined in RFC 8707.
  • The project issues JSON Web Tokens (JWTs), which your server verifies using the project's public keys.

New clients can request tickets:read, tickets:write, and offline_access. The user chooses which permissions to grant on the consent screen. You don't need to copy a client ID or manage callback URLs.

ValuePurpose
Client ID (client_id)Identifies the application requesting access, such as Claude Code or Codex.
Resource audience (aud)Identifies the server allowed to accept the token: http://127.0.0.1:18480/mcp.
Permission scopesIdentify the operations the user allowed, such as reading or closing tickets.

Use the same resource URL in the project defaults, the client connection, the server's discovery metadata, and the token verifier. The discovery metadata tells clients which authorization server to use.

2. Validate tokens and enforce scopes​

The following blocks include the complete source files. Highlighted lines connect the configuration, token verification, and permission checks; the surrounding code shows how they fit together.

Connect to your Ory project​

auth.ts reads your project URL from .env and fetches the project's OAuth settings from /.well-known/openid-configuration. This discovery document includes the endpoints used for login and token verification.

auth.ts
import { createOAuthVerifier } from "./verifier.js"

export async function loadOAuthSettings() {
const projectUrl = process.env.ORY_SDK_URL!
const resource = new URL("http://127.0.0.1:18480/mcp")

const response = await fetch(
`${projectUrl}/.well-known/openid-configuration`,
{ signal: AbortSignal.timeout(5000) },
)
if (!response.ok) throw new Error("OAuth discovery failed")
const metadata = await response.json()

const verifier = createOAuthVerifier({
projectUrl,
issuer: metadata.issuer,
resource: resource.href,
})

return { resource, metadata, verifier }
}

Validate the token​

verifier.ts verifies the token's signature using your project's public keys. It also checks who issued the token, when it expires, and whether its audience includes this MCP server. If the checks pass, it returns the granted scopes.

verifier.ts
import {
OAuthError,
OAuthErrorCode,
type OAuthTokenVerifier,
} from "@modelcontextprotocol/server"
import { createRemoteJWKSet, jwtVerify } from "jose"

export function createOAuthVerifier(settings: {
projectUrl: string
issuer: string
resource: string
}): OAuthTokenVerifier {
// The key set caches Ory’s public signing keys and refreshes them after rotation.
const jwks = createRemoteJWKSet(
new URL(`${settings.projectUrl}/.well-known/jwks.json`),
)

return {
async verifyAccessToken(token) {
try {
const { payload } = await jwtVerify<{
client_id: string
scp?: string[]
scope?: string
}>(token, jwks, {
issuer: settings.issuer,
audience: settings.resource,
algorithms: ["RS256", "ES256"],
requiredClaims: ["exp", "sub", "client_id"],
})

if (
typeof payload.sub !== "string" ||
!payload.sub ||
typeof payload.client_id !== "string" ||
!payload.client_id
) {
throw new Error("Unexpected token identity")
}

// The subject identifies the user; client_id identifies their OAuth application.
return {
token,
clientId: payload.client_id,
scopes: payload.scp ?? payload.scope?.split(" ") ?? [],
expiresAt: payload.exp,
extra: { subject: payload.sub },
}
} catch {
throw new OAuthError(
OAuthErrorCode.InvalidToken,
"Invalid access token",
)
}
},
}
}

Check each tool's scope​

server.ts handles three steps:

  • Check tickets:read and tickets:write. Without the required scope, the tool returns Forbidden before reading or changing a ticket.
  • Use mcpAuthMetadataRouter to tell the client where to start browser login.
  • Use requireBearerAuth to check the access token before a request reaches the MCP handler. Tool handlers receive the verified scopes in authInfo.
server.ts
import "dotenv/config"
import {
McpServer,
createMcpHandler,
type AuthInfo,
} from "@modelcontextprotocol/server"
import { toNodeHandler } from "@modelcontextprotocol/node"
import {
createMcpExpressApp,
mcpAuthMetadataRouter,
getOAuthProtectedResourceMetadataUrl,
requireBearerAuth,
} from "@modelcontextprotocol/express"
import * as z from "zod"
import { getTicket, closeTicket } from "./tickets.js"
import { loadOAuthSettings } from "./auth.js"

const { resource, metadata, verifier } = await loadOAuthSettings()

const failure = (text: string) => ({
isError: true,
content: [{ type: "text" as const, text }],
})

// Each request gets tool handlers bound to its caller’s verified permissions.
function createTicketServer({ authInfo }: { authInfo?: AuthInfo }) {
const server = new McpServer({ name: "ticket-tools", version: "1.0.0" })

server.registerTool(
"get_ticket",
{
description: "Read a ticket.",
inputSchema: z.object({ id: z.string().min(1) }),
},
async ({ id }) => {
if (!authInfo?.scopes.includes("tickets:read"))
return failure("Forbidden")

const ticket = getTicket(id)
if (!ticket) return failure("Ticket not found")

return { content: [{ type: "text", text: JSON.stringify(ticket) }] }
},
)

server.registerTool(
"close_ticket",
{
description: "Close a ticket.",
inputSchema: z.object({ id: z.string().min(1) }),
},
async ({ id }) => {
if (!authInfo?.scopes.includes("tickets:write"))
return failure("Forbidden")

const ticket = closeTicket(id)
if (!ticket) return failure("Ticket not found")

return { content: [{ type: "text", text: JSON.stringify(ticket) }] }
},
)

return server
}

const handler = toNodeHandler(createMcpHandler(createTicketServer))

const app = createMcpExpressApp({
host: "127.0.0.1",
allowedHosts: [resource.hostname],
})
// Discovery tells clients where to send the user for login and consent.
app.use(
mcpAuthMetadataRouter({
oauthMetadata: metadata,
resourceServerUrl: resource,
scopesSupported: ["tickets:read", "tickets:write", "offline_access"],
}),
)

app.all(
"/mcp",
requireBearerAuth({
verifier,
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resource),
}),
(req, res) => void handler(req, res, req.body),
)

app
.listen(18480, "127.0.0.1")
.on("listening", () => console.log(`MCP: ${resource}`))

In createTicketServer, each handler checks its required scope before accessing a ticket. The highlighted checks show why a read-only token can call get_ticket but cannot call close_ticket.

3. Start the server​

In the server terminal, check the server:

npm run check

Start the server. It loads the project settings from .env, so this command also works after reopening the terminal. Run it from ory-docs/code-examples/mcp-auth/typescript/oauth:

. ./.env
npm start

4. Sign in with read-only access​

Keep the server running. Open another terminal window or tab for your MCP client. This is your client terminal. Change to the ory-docs/code-examples/mcp-auth/typescript/oauth directory, then choose one client and use it throughout:

Add the connection and sign in:

claude mcp add --transport http --callback-port 6275 ticket-tutorial http://127.0.0.1:18480/mcp
claude mcp login ticket-tutorial

If this terminal has no browser, for example over SSH, use this command instead of the preceding login command:

claude mcp login ticket-tutorial --no-browser
  1. Open the printed URL in a browser on another machine.
  2. Sign in and choose the consent settings described below.
  3. Copy the full callback URL from the browser's address bar and paste it at the terminal prompt.

See Claude Code's OAuth setup for details.

In the browser, register or sign in as a test user. On the consent screen, use these settings:

Consent choiceSet it to
tickets:readOn
tickets:writeOff
Offline Access (offline_access)On
Remember my decisionUnchecked

tickets:read lets the client read tickets and tickets:write lets it close them. Offline Access lets the client renew its access without another login. Leaving Remember my decision unchecked lets you choose different permissions in step 6.

Consent screen with tickets and Offline Access on, tickets off, and Remember my decision unchecked.

Select Accept, then return to your client and wait for it to confirm the login.

5. Test read-only access​

In the client terminal, use your connected client to test the permissions:

  1. In Claude Code or Codex, start a conversation with claude or codex and run /mcp to confirm that ticket-tutorial is connected.
  2. Ask the agent to perform each action below with ticket-tutorial, one at a time.

If you use Inspector, open Tools, choose the tool, enter T-100 as id, and select Execute Tool for each action instead. Approving a tool call in the client is separate from the ticket permissions you granted in the browser.

ActionToolExpected result
Read ticket T-100.get_ticketopen
Close ticket T-100.close_ticketForbidden
Read ticket T-100 again.get_ticketStill open

Forbidden is the intended result: you left tickets:write off.

6. Grant write access​

Keep the server running in the server terminal so you continue with the same ticket. In the client terminal, sign in again with your chosen client:

  • For Claude Code or Codex, exit the conversation with /exit and run the login command from step 4 again.
  • For Inspector, stop it with Ctrl+C and clear its login state with the following command. Then run the Inspector start command from step 4 again.
rm -rf "$INSPECTOR_STATE"
export INSPECTOR_STATE=$(mktemp -d)
  1. On the consent screen, leave tickets:read and Offline Access on, and turn tickets:write on.
  2. Leave Remember my decision unchecked and select Accept.
  3. In the client terminal, start a new Claude Code or Codex conversation so the client uses the new token. For Inspector, wait for Connected.
  4. Close T-100 and read it again. Its status is now closed.

To repeat the exercise, restart the server with npm start to reset the in-memory tickets. All authenticated users share these tickets; scopes control operations, not which data a user can see.

Troubleshooting​

SymptomCheck
Registration is unavailableRun the configuration check. DCR must be enabled and discovery must advertise your project's /oauth2/register URL.
invalid_targetThe requested resource must match the client's audience exactly, including scheme, host, port, path, and trailing slash. Clients registered before you set the default audience keep their old audience: clear the client's saved registration and sign in again.
Login succeeds but the server returns 401The token must be a JWT whose aud includes http://127.0.0.1:18480/mcp. Confirm the project uses JWT access tokens. A client registered with its own access-token strategy overrides the project default; create a fresh registration.
Consent page does not appearRevoke remembered consent, then sign in again. Clearing client tokens does not clear remembered consent.
Check configuration and discovery

Check the configuration​

Leave the MCP server running. Open another terminal and change to the ory-docs/code-examples/mcp-auth/typescript/oauth directory to run these checks.

Check the project settings and that discovery advertises the registration endpoint. Both checks print true:

. ./.env
ory get oauth2-config --project "$ORY_PROJECT_ID" --format json | jq -e '
.feature_flags.resource_indicators_enabled == true and
.oidc.dynamic_client_registration.enabled == true and
.oidc.dynamic_client_registration.default_audience == ["http://127.0.0.1:18480/mcp"] and
.oidc.dynamic_client_registration.default_scope == ["tickets:read","tickets:write","offline_access"] and
.strategies.access_token == "jwt"' || {
printf '%s\n' 'Project must enable RFC 8707, DCR, the tutorial audience and scopes, and JWT access tokens.' >&2
exit 1
}
curl -fsS "$ORY_SDK_URL/.well-known/openid-configuration" | \
jq -e --arg registration "$ORY_SDK_URL/oauth2/register" '.registration_endpoint == $registration' || {
printf '%s\n' 'Discovery must advertise the project /oauth2/register endpoint.' >&2
exit 1
}

With the server running, check its discovery responses:

curl -i http://127.0.0.1:18480/mcp
curl -fsS http://127.0.0.1:18480/.well-known/oauth-protected-resource/mcp

The first returns HTTP 401 with a resource_metadata challenge. The second identifies the MCP resource and your project as its authorization server.

Clean up​

  1. In the client terminal, exit your conversation or stop Inspector.
  2. In the client terminal, remove the client connection with claude mcp remove ticket-tutorial or codex mcp remove ticket-tutorial. If you used Inspector, run rm -rf "$INSPECTOR_STATE" inspector.json instead.
  3. In the server terminal, stop the MCP server.

In the server terminal, delete the tutorial's dynamically registered clients. The list shows each client's ID, name, callback URLs, and audience. Enter the IDs of your tutorial clients one at a time:

. ./.env
ory list oauth2-clients --project "$ORY_PROJECT_ID" --format json | \
jq '.items[] | {client_id, client_name, redirect_uris, audience}'
printf 'Tutorial client ID to delete (repeat for each tutorial registration): '
read -r TUTORIAL_CLIENT_ID
ory delete oauth2-client "$TUTORIAL_CLIENT_ID" --project "$ORY_PROJECT_ID" --yes
unset TUTORIAL_CLIENT_ID

Delete the test identity you created:

. ./.env
ory list identities --project "$ORY_PROJECT_ID" --format json
printf 'Test identity ID to delete: '
read -r TEST_IDENTITY_ID
ory delete identity "$TEST_IDENTITY_ID" --project "$ORY_PROJECT_ID" --yes

In the server terminal, delete the setup files and clear the variables:

rm -f project.json oauth-config.json .env
unset TEST_IDENTITY_ID ORY_PROJECT_ID ORY_SDK_URL

Repeat the unset command in any other terminal where you loaded the project settings. Each terminal keeps its own variables.

Deploy your server​

Replace the loopback resource with your server's public HTTPS URL, such as https://mcp.example.com/mcp, in the client connection, project default audiences, and auth.ts. Existing registrations keep their old audience; see invalid_target.

The resource URL identifies your server in tokens and discovery. It is not the address the server listens on. The server keeps listening on 127.0.0.1:18480 and rejects requests whose Host header does not match the resource's host name. Run a reverse proxy on the same machine that terminates TLS for the public host name and forwards all requests to http://127.0.0.1:18480 with the original Host header.

The server verifies JWTs locally. Revoking consent stops future refreshes, but an issued JWT still passes verification until it expires. Choose token lifetimes that fit your application, and use token introspection when you need an online status check. See JWT access tokens, MCP authorization, and token audiences in Ory Hydra Authorization.

See the complete OAuth example.