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

Skip to main content

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.

Ory Hydra Authorization handles the OAuth protocol. The sample answers two questions: who is the user? and what can this client do?

  1. Your MCP client sends the browser to Ory Hydra to request access.
  2. Ory Hydra redirects to /login with a login_challenge. The app uses this value to fetch the login request.
  3. You select Continue as demo user. The app tells Ory Hydra that the user is ticket-agent, then redirects the browser to the redirect_to URL returned by Ory Hydra.
  4. Ory Hydra redirects to /consent with a consent_challenge. The app fetches the requested permissions and shows them to you.
  5. You choose scopes and select Accept. The app sends the granted scopes and audiences to Ory Hydra. Deny access rejects the request instead.
  6. 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:

login-consent.ts
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:

login-consent.ts
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.

auth.ts
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.

verifier.ts
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.

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 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

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.

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 choiceSet it to
tickets:readChecked
tickets:writeUnchecked
offline_accessChecked
Remember my decisionUnchecked

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.

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 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 /exit and 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​

SymptomCheck
Consent page does not appearRevoke remembered consent, then sign in again. Clearing client tokens does not clear remembered consent. Use the local administrative endpoint.
Callback rejectedRegister 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 401Check OAUTH_CLIENT_ID in .env. The token must belong to this server's dedicated client and must not contain a conflicting audience.
MCP returns 500Check 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​

  1. Exit your conversation or stop Inspector in the client terminal.
  2. 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. 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.