Add browser login with Ory Hydra Authorization (OEL)
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, Codex, or MCP Inspector and chooses whether the client can read tickets, close them, or both.
This guide runs Ory Hydra Authorization with Ory Enterprise License (OEL) on your machine. It uses OEL 26.4.7, which supports OAuth 2.0 resource indicators for MCP. Clients register automatically, and each access token identifies the MCP server allowed to accept it. The login and consent sample runs alongside Ory Hydra Authorization.
For a hosted setup, follow Browser login with Ory Network. For the current open-source release, use the open-source guide, which registers clients and callback URLs explicitly.
First, grant read access and confirm that closing a ticket fails. Then grant write access and close it.
Prerequisites
Install Docker with Compose, curl, jq, and OpenSSL.
Use one terminal window or tab for setup and running the server. The steps below call this the server terminal. Keep ports 13000, 14444, 14445, and 18480 free. Claude Code, Codex, and Inspector also use ports 6275, 6276, and 6274 respectively.
You need an Ory Enterprise License and access to the OEL container registry. Sign in to the registry using the credentials provided with your license before starting the containers.
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-oel
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. Start Ory Hydra Authorization
The supplied compose.yaml
starts a PostgreSQL database, runs the database migrations, and starts Ory Hydra Authorization and the login/consent sample. The
database stays in a Docker volume between restarts.
How the login and consent app works
Ory Hydra Authorization handles the OAuth protocol. The sample answers two questions: who is the user? and what can this client do?
- Your MCP client sends the browser to Ory Hydra to request access.
- Ory Hydra redirects to
/loginwith alogin_challenge. The app uses this value to fetch the login request. - You select Continue as demo user. The app tells Ory Hydra that the user is
ticket-agent, then redirects the browser to theredirect_toURL returned by Ory Hydra. - Ory Hydra redirects to
/consentwith aconsent_challenge. The app fetches the requested permissions and shows them to you. - You choose scopes and select Accept. The app sends the granted scopes and audiences to Ory Hydra. Deny access rejects the request instead.
- The app follows Ory Hydra's next
redirect_to. Ory Hydra returns an authorization code to your MCP client, which exchanges it for tokens. The MCP server uses the access token to check which tools the client can call.
The login handler accepts a user identifier, called the subject. The demo button simulates authentication. In your
application, your login system verifies the user and supplies this identifier. If Ory Hydra remembers a previous login,
request.skip is true and its subject identifies the user:
async function login(
challenge: string,
form: URLSearchParams | undefined,
res: ServerResponse,
) {
const query = `login_challenge=${encodeURIComponent(challenge)}`
const request: LoginRequest = await hydra(`login?${query}`)
if (form?.get("action") === "deny") {
redirect(
res,
await hydra(`login/reject?${query}`, {
error: "access_denied",
error_description: "The user denied access",
}),
)
return
}
if (request.skip || form) {
const subject = request.skip ? request.subject : "ticket-agent"
const result = await hydra(`login/accept?${query}`, {
subject,
remember: true,
remember_for: 3600,
})
redirect(res, result)
return
}
render(
res,
"1. Identify a user",
`
<p>Ory Hydra Authorization asks this app who is signing in. For this tutorial, you can
continue as <code>ticket-agent</code> without entering a password.</p>
<p>The button simulates authentication. In your application, your login
system would verify the user and supply their identifier as the subject.</p>
<form method="post" action="/login">
<input type="hidden" name="challenge" value="${escape(challenge)}">
<button name="action" value="accept">Continue as demo user</button>
<button name="action" value="deny">Deny access</button>
</form>
<p>Next: Ory Hydra asks which permissions you want to grant to your MCP client.</p>
`,
)
}
The consent handler sends your selected scopes to Ory Hydra. Scopes describe what the client can do; audiences identify
where the token can be used. grantedAudience combines Ory Hydra's requested audiences with requested resources allowed for
this client:
async function consent(
challenge: string,
form: URLSearchParams | undefined,
res: ServerResponse,
) {
const query = `consent_challenge=${encodeURIComponent(challenge)}`
const request: ConsentRequest = await hydra(`consent?${query}`)
if (form?.get("action") === "deny") {
redirect(
res,
await hydra(`consent/reject?${query}`, {
error: "access_denied",
error_description: "The user denied access",
}),
)
return
}
if (request.skip || form) {
const scopes = request.skip
? request.requested_scope ?? []
: form?.getAll("scope") ?? []
if (scopes.some((scope) => !request.requested_scope?.includes(scope))) {
res.writeHead(400).end("Unexpected permission")
return
}
const result = await hydra(`consent/accept?${query}`, {
grant_scope: scopes,
grant_access_token_audience: grantedAudience(request),
remember: form?.get("remember") === "on",
remember_for: 3600,
})
redirect(res, result)
return
}
render(
res,
"2. Choose permissions",
`
<p><strong>${escape(request.client?.client_name || "Your MCP client")}</strong>
wants to act as <code>${escape(request.subject ?? "")}</code>.</p>
<p>Choose what it can do. Your MCP server checks these permissions on each tool call.</p>
<form method="post" action="/consent">
<input type="hidden" name="challenge" value="${escape(challenge)}">
<fieldset>
<legend>Permissions</legend>
${(request.requested_scope ?? []).map(scopeField).join("\n")}
</fieldset>
${resourceList(request)}
<label><input type="checkbox" name="remember"> Remember my decision</label>
<p>Leave this unchecked to choose different permissions the next time you connect.</p>
<button name="action" value="accept">Accept</button>
<button name="action" value="deny">Deny access</button>
</form>
<p>Next: Ory Hydra Authorization returns you to your MCP client, which exchanges an authorization code for tokens.</p>
`,
)
}
Remembering login and remembering consent are separate decisions. Keep Remember my decision unchecked so you can change the permissions during this exercise. When Ory Hydra indicates that consent can be skipped, the app still sends the approved audiences.
See the login and consent flow for how to connect your own authentication system.
The complete
login-consent.ts
provides both pages with one simulated user. The example links to this shared file; keep the self-hosted directory alongside the
OAuth examples.
Generate a system secret once and save it in .env.hydra. Keep this file: Ory Hydra Authorization needs the same secret after a
restart. The command restricts the file to your user, adds it to .gitignore, and refuses to overwrite an existing file:
(umask 077; set -C
printf 'HYDRA_SYSTEM_SECRET=%s\n' "$(openssl rand -hex 32)" > .env.hydra || exit 1
printf '\n.env.hydra\n' >> .gitignore
)
If .env.hydra already exists, reuse it and continue to the next step.
Save the MCP server's settings in this example's .env. The server loads the file each time it starts:
umask 077
cat > .env <<'ENV'
HYDRA_PUBLIC_URL="http://127.0.0.1:14444"
HYDRA_ADMIN_URL="http://127.0.0.1:14445"
ENV
Start the containers. Compose reads .env.hydra to load the secret and waits until the services are ready:
docker compose --env-file .env.hydra up -d --wait
If startup fails, run docker compose --env-file .env.hydra logs and check that the required ports are free.
The public OAuth endpoint is http://127.0.0.1:14444. The administrative endpoint is http://127.0.0.1:14445. Your browser uses
http://127.0.0.1:13000 for login and consent. All three are available only on this machine.
The configuration enables automatic client registration, also called dynamic client registration (DCR). New clients receive
tickets:read, tickets:write, and offline_access as their default scopes, and http://127.0.0.1:18480/mcp as their allowed
resource audience. The client requests this resource during login and token exchange. You don't need to copy a client ID or manage
callback URLs.
The consent sample includes approved requested_resources in grant_access_token_audience, including when consent is skipped.
This lets Ory Hydra Authorization issue a JWT for the MCP server. See
granting resources in a consent app.
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 Ory Hydra Authorization
auth.ts loads the public URL from
.env and fetches OAuth discovery metadata. This tells the client where to start login and exchange an authorization code for an
access token.
import { createOAuthVerifier } from "./verifier.js"
export async function loadOAuthSettings() {
const publicUrl = process.env.HYDRA_PUBLIC_URL
if (!publicUrl) throw new Error("Set HYDRA_PUBLIC_URL in .env")
const resource = new URL("http://127.0.0.1:18480/mcp")
const response = await fetch(
`${publicUrl}/.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: publicUrl,
issuer: metadata.issuer,
resource: resource.href,
})
return { resource, metadata, verifier }
}
Validate the token
verifier.ts checks the JWT
signature, issuer, expiry, and audience using Ory Hydra Authorization's public signing keys. A token must identify
http://127.0.0.1:18480/mcp as an audience before this server accepts it.
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
In server.ts,
requireBearerAuth verifies each access token before the request reaches the MCP handler. The tool handlers then check
tickets:read or tickets:write before reading or changing a ticket. Discovery metadata tells clients which OAuth server to use.
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 files, then start the server:
npm run check
. ./.env
npm start
Run these commands from ory-docs/code-examples/mcp-auth/typescript/oauth-oel. The start command loads .env, so it also works
after reopening your terminal.
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
ory-docs/code-examples/mcp-auth/typescript/oauth-oel, then choose one client and use it throughout.
- Claude Code
- Codex
- MCP Inspector
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 the terminal has no browser, use this command instead of the preceding login command:
claude mcp login ticket-tutorial --no-browser
Open the printed URL in a browser, sign in, and paste the full callback URL back at the terminal prompt. The browser must be able to reach the local services on this machine.
Add the connection. If ticket-tutorial already exists, remove it first with codex mcp remove ticket-tutorial:
mkdir -p "${CODEX_HOME:-$HOME/.codex}"
cat >> "${CODEX_HOME:-$HOME/.codex}/config.toml" <<'TOML'
[mcp_servers.ticket-tutorial]
url = "http://127.0.0.1:18480/mcp"
scopes = ["tickets:read", "tickets:write", "offline_access"]
TOML
Sign in:
codex -c mcp_oauth_callback_port=6276 -c 'mcp_oauth_callback_url="http://127.0.0.1:6276/callback"' \
mcp login ticket-tutorial --scopes tickets:read,tickets:write,offline_access
If the terminal has no browser, use this command instead:
codex -c mcp_oauth_callback_port=6276 -c 'mcp_oauth_callback_url="http://127.0.0.1:6276/callback"' \
mcp login ticket-tutorial --scopes tickets:read,tickets:write,offline_access --no-browser
Open the printed URL in a browser, sign in, and paste the full callback URL back at the terminal prompt. Use the same callback settings on later logins. The browser must be able to reach the local services on this machine.
Inspector lets you call tools in your browser without an AI subscription. Create its configuration, then start it:
jq -n '{mcpServers: {tickets: {
type: "http",
url: "http://127.0.0.1:18480/mcp",
oauth: {
scopes: "tickets:read tickets:write offline_access",
requestRefreshToken: true
}
}}}' > inspector.json
export INSPECTOR_STATE=$(mktemp -d)
HOST=127.0.0.1 CLIENT_PORT=6274 MCP_STORAGE_DIR="$INSPECTOR_STATE" \
MCP_INSPECTOR_SECRET_STORE=memory \
npm exec --yes --package @modelcontextprotocol/[email protected] -- \
mcp-inspector --web --config inspector.json --server tickets
Open the printed URL and turn on the tickets connection switch.
Select Continue as demo user. The app identifies you to Ory Hydra as ticket-agent. If Ory Hydra remembers your earlier
login, you go directly to consent.
On the consent page, use these settings:
| Consent choice | Set it to |
|---|---|
| tickets:read | Checked |
| tickets:write | Unchecked |
| offline_access | Checked |
| Remember my decision | Unchecked |
Select Accept, return to your client, and wait for it to confirm the login. Leaving Remember my decision unchecked lets you choose different permissions in step 6.
5. Test read-only access
In Claude Code or Codex, start a conversation and run /mcp to confirm ticket-tutorial is connected. Ask it to perform each
action below, one at a time. Approving a tool call in the client is separate from the permissions you granted in the browser.
In Inspector, open Tools, choose the tool, enter T-100 as id, and select Execute Tool.
| Action | Tool | Expected result |
|---|---|---|
| Read ticket T-100. | get_ticket | open |
| Close ticket T-100. | close_ticket | Forbidden |
| Read ticket T-100 again. | get_ticket | Still open |
Forbidden is the intended result: you left tickets:write unchecked.
6. Grant write access
Keep the MCP server running so you continue with the same ticket. In the client terminal:
- For Claude Code or Codex, exit the conversation with
/exitand repeat the login command from step 4. - For Inspector, stop it with Ctrl+C, clear its login state with the command below, then repeat the start command from step 4.
rm -rf "$INSPECTOR_STATE"
export INSPECTOR_STATE=$(mktemp -d)
On the consent page, check tickets:read, tickets:write, and offline_access. Leave Remember my decision unchecked and select Accept. Start a new Claude Code or Codex conversation, or wait for Inspector to show Connected.
Close T-100 and read it again. Its status is now closed.
Restart the MCP server to reset the in-memory tickets. All authenticated users share these tickets; scopes control operations, not which data a user can see.
Troubleshooting
| Symptom | Check |
|---|---|
| Consent page does not appear | Revoke remembered consent, then sign in again. Clearing client tokens does not clear remembered consent. Use the local administrative endpoint. |
invalid_target | Use the exact same resource URL in the DCR default audience, discovery, and verifier. The consent app must grant the requested resource. |
| Login succeeds but MCP returns 401 | Confirm JWT access tokens are enabled and the token audience contains the MCP resource. Clear old client registrations after changing defaults. |
Check configuration and discovery
With the MCP server running, run these commands from another terminal:
curl -si http://127.0.0.1:18480/mcp
curl --fail --silent --show-error \
http://127.0.0.1:18480/.well-known/oauth-protected-resource/mcp | jq
The first returns HTTP 401 with a resource_metadata challenge. The second identifies the MCP resource and Ory Hydra
Authorization's public URL as its authorization server.
Clean up
- Exit your conversation or stop Inspector in the client terminal.
- Remove the client connection with
claude mcp remove ticket-tutorialorcodex mcp remove ticket-tutorial. If you used Inspector, runrm -rf "$INSPECTOR_STATE" inspector.jsoninstead. - Stop the MCP server in the server terminal.
Remove the tutorial containers and database volume. This deletes their client registrations and remembered consent:
docker compose --env-file .env.hydra down -v
Delete the local settings and clear the setup variables:
rm -f .env .env.hydra oauth-client.json inspector.json
unset HYDRA_PUBLIC_URL HYDRA_ADMIN_URL OAUTH_CLIENT_ID CALLBACK_URL INSPECTOR_STATE
Repeat the unset command in any other terminal where you loaded the settings.
Deploy your server
Use HTTPS URLs for the public OAuth service, login and consent app, and MCP server. Connect your existing authentication system
using the login and consent flow. Replace the simulated login with real user
authentication and remove --dev from the Ory Hydra Authorization command. Keep the administrative API on a private network.
The MCP resource URL identifies your server in discovery and access tokens. It is separate from the address the server listens on.
Keep the MCP process behind a reverse proxy that terminates TLS and forwards the original Host header. Update the resource in
auth.ts and the client connection to the public HTTPS URL.
Update the DCR default audience too. Existing registrations keep their old audience, so create fresh registrations after changing it. The MCP server verifies JWTs locally; an issued token remains usable until expiry even after consent is revoked. Use token introspection when you need an online status check.
See the complete OEL example.