Add browser login with Ory Hydra Authorization (open source)
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 the open-source release of Ory Hydra Authorization on your machine. It uses v26.2.0, which does not support OAuth 2.0 resource indicators yet. You register one public OAuth client for this MCP server and give it exact callback URLs. The server checks that each token belongs to that client before checking its permissions.
This compatibility approach does not provide MCP's full resource-audience validation. Keep the registration dedicated to this server. For resource indicators and automatic client registration, follow the OEL guide or Ory Network guide.
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.
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-oss
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.
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"
OAUTH_CLIENT_ID="ticket-tutorial"
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.
Register the tutorial's public OAuth client. It uses PKCE instead of a client secret, so the token exchange must come from the client that started login:
. ./.env
jq -n --arg id "$OAUTH_CLIENT_ID" '{
client_id: $id,
client_name: "Ticket MCP tutorial",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
redirect_uris: ["http://localhost:6275/callback", "http://127.0.0.1:6274/oauth/callback"],
scope: "tickets:read tickets:write offline_access"
}' | curl --fail --silent --show-error \
-H 'Content-Type: application/json' --data-binary @- \
"$HYDRA_ADMIN_URL/admin/clients" > oauth-client.json
The registration allows the fixed Claude Code and Inspector callbacks. If you choose Codex, step 4 adds its complete callback
before login. OAUTH_CLIENT_ID in .env identifies this registration; it is not a password. Create a separate registration when
you add another MCP server, even if both servers use this same Ory Hydra Authorization instance.
offline_access and the refresh_token grant let the client renew access without another login. They do not grant permission to
read or close tickets. Dynamic client registration remains disabled in this setup.
1.1. 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.
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 adminUrl = process.env.HYDRA_ADMIN_URL
if (!adminUrl) throw new Error("Set HYDRA_ADMIN_URL in .env")
const verifier = createOAuthVerifier({
adminUrl,
clientId: process.env.OAUTH_CLIENT_ID ?? "",
resource: resource.href,
})
return { resource, metadata, verifier }
}
Validate the token
verifier.ts asks the
administrative API whether the token is active, a process called introspection. It requires an access token and rejects expired
tokens, tokens from other client IDs, and tokens with an audience that excludes this server. An empty audience is accepted only
with the dedicated client ID, because this release ignores the MCP client's resource parameter.
Only your MCP server needs access to the administrative API. Never give that endpoint to an MCP client or expose it publicly.
import {
OAuthError,
OAuthErrorCode,
type OAuthTokenVerifier,
} from "@modelcontextprotocol/server"
import * as z from "zod"
const tokenInfo = z.object({
active: z.literal(true),
token_use: z.literal("access_token"),
sub: z.string().min(1),
client_id: z.string().min(1),
exp: z.number(),
scope: z.string().default(""),
aud: z.array(z.string()).default([]),
})
export function createOAuthVerifier(settings: {
adminUrl: string
clientId: string
resource: string
}): OAuthTokenVerifier {
if (!settings.clientId) throw new Error("Set OAUTH_CLIENT_ID in .env")
return {
async verifyAccessToken(token) {
const response = await fetch(
`${settings.adminUrl}/admin/oauth2/introspect`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ token }),
signal: AbortSignal.timeout(5000),
},
)
if (!response.ok) throw new Error("Hydra introspection failed")
const result = tokenInfo.safeParse(await response.json())
if (
!result.success ||
result.data.exp <= Date.now() / 1000 ||
result.data.client_id !== settings.clientId ||
(result.data.aud.length > 0 &&
!result.data.aud.includes(settings.resource))
) {
throw new OAuthError(
OAuthErrorCode.InvalidToken,
"Invalid access token",
)
}
return {
token,
clientId: result.data.client_id,
scopes: result.data.scope.split(/\s+/).filter(Boolean),
expiresAt: result.data.exp,
extra: { subject: result.data.sub },
}
},
}
}
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-oss. 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-oss, then choose one client and use it throughout.
Load the public client ID in the client terminal:
. ./.env
export OAUTH_CLIENT_ID
- Claude Code
- Codex
- MCP Inspector
Add the connection and sign in:
claude mcp add --transport http --callback-port 6275 --client-id "$OAUTH_CLIENT_ID" 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.
In the server terminal, stop the MCP server with Ctrl+C. Keep the containers running. This prevents Codex from starting login before you register its callback.
In the client terminal, add the connection with a fixed callback port and base URL:
codex -c mcp_oauth_callback_port=6276 -c 'mcp_oauth_callback_url="http://127.0.0.1:6276/callback"' \
mcp add ticket-tutorial --url http://127.0.0.1:18480/mcp --oauth-client-id "$OAUTH_CLIENT_ID"
Codex saves the connection and prints an OAuth callback URL. It may append a server-specific path to the base URL. Copy the complete URL, including that path. In the server terminal, register it:
. ./.env
printf 'Paste the complete OAuth callback URL printed by Codex: '
read -r CALLBACK_URL
jq -n --arg callback "$CALLBACK_URL" \
'[{op: "add", path: "/redirect_uris/-", value: $callback}]' | \
curl --fail --silent --show-error -X PATCH \
-H 'Content-Type: application/json' --data-binary @- \
"$HYDRA_ADMIN_URL/admin/clients/$OAUTH_CLIENT_ID" > oauth-client.json
Restart the MCP server with npm start. In the client terminal, 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 --arg clientId "$OAUTH_CLIENT_ID" '{mcpServers: {tickets: {
type: "http",
url: "http://127.0.0.1:18480/mcp",
oauth: {
clientId: $clientId,
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. |
| Callback rejected | Register the exact URL sent by your client, including scheme, hostname, port, and path. For Codex, use its complete printed callback. |
| Login succeeds but MCP returns 401 | Check OAUTH_CLIENT_ID in .env. The token must belong to this server's dedicated client and must not contain a conflicting audience. |
| MCP returns 500 | Check that the MCP server can reach HYDRA_ADMIN_URL. Introspection must succeed before a tool runs. |
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. 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.
Keep one OAuth registration per MCP server and require its client ID in the verifier. Do not share the registration with other
APIs. Adding a client audience allowlist alone does not make this release process the standard resource parameter. When you move
to a release that supports resource indicators, use the OEL guide's resource-based verification instead
of relaxing the dedicated-client check.
See the complete open source example.