Use API keys with your MCP server
Run a ticket server protected by application API keys. For an introduction to its tools, see Build an MCP server with TypeScript. API keys suit clients that run without a person signing in, such as an automated job that copies ticket information into GitHub. Issue a read-only key with Ory Talos Secure API Keys, check that it can read tickets but not close them, then revoke it.
Choose where to verify the keys. Selecting a deployment tab applies that choice to the other deployment tabs on this page:
- Select Ory Network to verify keys in your Ory Network project. This option requires the Ory CLI.
- Select Self-hosted to run Ory Talos Secure API Keys locally. This option requires Docker with Compose and OpenSSL.
You choose your MCP client separately in Connect your client.
Both options require curl, jq, and an MCP client that can send a bearer header. The example includes the verifier and MCP
server for your chosen deployment.
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
From the ory-docs repository root, select the directory for your deployment. The commands also create a private .env file:
- Ory Network
- Self-hosted
cd code-examples/mcp-auth/typescript/api-keys-network
umask 077
touch .env
cd code-examples/mcp-auth/typescript/api-keys
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. Set up the verifier
- Ory Network
- Self-hosted
In the server terminal, sign in with the Ory CLI and create a development project. Select your workspace when prompted:
ory auth
ory create project --name "Ticket MCP tutorial" --environment dev --use-project --format json > project.json
export ORY_PROJECT_ID=$(jq -er '.id' project.json)
export ORY_SDK_URL="https://$(jq -er '.slug' project.json).projects.oryapis.com"
This guide uses two different keys. You, the developer, use a project API key to authorize calls to Ory Network through the Ory SDK or API. You then issue application API keys to third parties so their clients can authenticate calls to your MCP server. In this tutorial, you also act as a third-party client to test the application key.
| Key | Purpose | Who keeps it |
|---|---|---|
Project API key (ORY_PROJECT_API_KEY) | Authorize Ory SDK or API calls to issue, verify, and revoke application keys. | You, the developer, and your trusted server. |
Application API key (APPLICATION_API_KEY) | Authenticate calls to your MCP server with specific ticket permissions. | The third party running an MCP client or automated job. |
The examples below call the Ory API directly with curl and fetch. The project API key serves the same purpose when you use an
Ory SDK. Create a project API key for your new project in Ory
Console, then paste it at this hidden prompt. umask 077 restricts newly created credential files to your user:
umask 077
printf 'Project API key (input hidden): '
read -r -s ORY_PROJECT_API_KEY
printf '\n'
export ORY_PROJECT_API_KEY
Keep the project API key in your trusted development and server environments. Never give it to third parties; give their MCP clients only an application key.
The supplied compose.yaml
starts Ory Talos Secure API Keys with a local SQLite database and runs the migrations before starting the server.
Save a generated secret in .env.talos. The supplied Compose configuration loads this file whenever the verifier starts. The
setup command below creates the file with this structure:
.env.talos (example)
SECRETS_HMAC_CURRENT=<generated secret>
Create the secret file once
Run this command in the server terminal. It restricts access to your user and refuses to overwrite an existing file:
umask 077
(
set -e
set -o noclobber
secret=$(openssl rand -hex 32)
printf 'SECRETS_HMAC_CURRENT=%s\n' "$secret" > .env.talos
)
Add .env.talos to your .gitignore so the secret stays out of source control:
printf '\n.env.talos\n' >> .gitignore
Keep .env.talos while you reuse the database; its secret protects your stored keys.
Start the verifier
Compose waits until the verifier is ready. This command also works after reopening the terminal; run it from the selected example directory:
docker compose up -d --wait
The local admin API is unauthenticated. Keep it accessible only from your machine on the loopback address 127.0.0.1. See
protect the admin API before deploying it elsewhere. See the
configuration reference for deployment settings.
2. Issue a read-only application key
Use curl to issue and revoke application keys; the Ory CLI has no commands for them. The scopes field grants permission to
read tickets, and ttl makes the key expire after one hour. Run this request in the server terminal:
- Ory Network
- Self-hosted
curl -fsS "$ORY_SDK_URL"/v2alpha1/admin/issuedApiKeys \
-H "Authorization: Bearer $ORY_PROJECT_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "Ticket reader",
"actor_id": "ticket-agent",
"scopes": ["tickets:read"],
"ttl": "1h"
}' \
> issued.credentials.json
export APPLICATION_API_KEY=$(jq -er '.secret' issued.credentials.json)
export KEY_ID=$(jq -er '.issued_api_key.key_id' issued.credentials.json)
curl -fsS http://127.0.0.1:14430/v2alpha1/admin/issuedApiKeys \
-H 'Content-Type: application/json' \
-d '{
"name": "Ticket reader",
"actor_id": "ticket-agent",
"scopes": ["tickets:read"],
"ttl": "1h"
}' \
> issued.credentials.json
export APPLICATION_API_KEY=$(jq -er '.secret' issued.credentials.json)
export KEY_ID=$(jq -er '.issued_api_key.key_id' issued.credentials.json)
Use trusted provisioning code to assign the key's identity (actor_id) and permissions (scopes). Never let clients choose their
own permissions. APPLICATION_API_KEY is the client credential and KEY_ID identifies it for revocation. Keep
issued.credentials.json private and out of source control.
3. Add verification and scope checks
Your client sends an application key. The server asks Ory Talos Secure API Keys whether it is valid. Each tool then checks whether the key grants the permission it needs.
The following blocks include the complete verifier and server files. Highlighted lines show where credentials become verified permissions and where the server enforces them.
Verify the application key
In verifier.ts, verifyAccessToken(applicationKey) sends the client's key to the verification endpoint. The request has a
five-second timeout. The complete verifier also shows how the response becomes the caller’s identity and permissions:
- Ory Network
- Self-hosted
The third-party client sends its application key to your MCP server. Your server uses its own project API key to authorize the verification call to Ory Network. The application key being checked goes in the request body:
import {
OAuthError,
OAuthErrorCode,
type OAuthTokenVerifier,
} from "@modelcontextprotocol/server"
export function createApiKeyVerifier(
projectUrl: string,
projectApiKey: string,
): OAuthTokenVerifier {
return {
async verifyAccessToken(applicationKey) {
// Talos checks expiry and revocation for each supplied credential.
const response = await fetch(
`${projectUrl}/v2alpha1/admin/apiKeys:verify`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// The project key authorizes verification; the caller's key goes in the body.
Authorization: `Bearer ${projectApiKey}`,
},
body: JSON.stringify({ credential: applicationKey }),
signal: AbortSignal.timeout(5000),
},
)
if (!response.ok) throw new Error("API key verification failed")
const key = await response.json()
if (!key.is_valid) {
throw new OAuthError(OAuthErrorCode.InvalidToken, "Invalid API key")
}
return {
token: applicationKey,
clientId: key.actor_id,
scopes: key.scopes ?? [],
// The SDK rejects tokens without a numeric expiry, so a key that never expires reports Infinity.
expiresAt: key.expire_time
? Date.parse(key.expire_time) / 1000
: Infinity,
extra: { subject: key.actor_id, metadata: key.metadata },
}
},
}
}
Send the application key to your local verifier:
import {
OAuthError,
OAuthErrorCode,
type OAuthTokenVerifier,
} from "@modelcontextprotocol/server"
export function createApiKeyVerifier(talosUrl: string): OAuthTokenVerifier {
return {
async verifyAccessToken(applicationKey) {
// Talos checks expiry and revocation for each supplied credential.
const response = await fetch(
`${talosUrl}/v2alpha1/admin/apiKeys:verify`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ credential: applicationKey }),
signal: AbortSignal.timeout(5000),
},
)
if (!response.ok) throw new Error("API key verification failed")
const key = await response.json()
if (!key.is_valid) {
throw new OAuthError(OAuthErrorCode.InvalidToken, "Invalid API key")
}
return {
token: applicationKey,
clientId: key.actor_id,
scopes: key.scopes ?? [],
// The SDK rejects tokens without a numeric expiry, so a key that never expires reports Infinity.
expiresAt: key.expire_time
? Date.parse(key.expire_time) / 1000
: Infinity,
extra: { subject: key.actor_id, metadata: key.metadata },
}
},
}
}
In the verifier above, response.ok distinguishes a failed verification request from an invalid key. An invalid, expired, or
revoked key must stop the MCP request before a tool runs.
For a valid key, return its verified identity, permissions, and expiration in the format the MCP SDK expects. The token field
holds the application key. Expiration is expressed in seconds; Infinity represents a key without an expiration.
Protect the MCP endpoint
In server.ts, place requireBearerAuth before the MCP handler. It calls your verifier on every request, so revoking a key also
stops requests from an already connected client:
- Ory Network
- Self-hosted
import {
McpServer,
createMcpHandler,
type AuthInfo,
} from "@modelcontextprotocol/server"
import { toNodeHandler } from "@modelcontextprotocol/node"
import {
createMcpExpressApp,
requireBearerAuth,
} from "@modelcontextprotocol/express"
import * as z from "zod"
import { getTicket, closeTicket } from "./tickets.js"
import { createApiKeyVerifier } from "./verifier.js"
const verifier = createApiKeyVerifier(
process.env.ORY_SDK_URL!,
process.env.ORY_PROJECT_API_KEY!,
)
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" })
app.all(
"/mcp",
requireBearerAuth({ verifier }),
(req, res) => void handler(req, res, req.body),
)
app
.listen(18480, "127.0.0.1")
.on("listening", () => console.log("MCP: http://127.0.0.1:18480/mcp"))
import {
McpServer,
createMcpHandler,
type AuthInfo,
} from "@modelcontextprotocol/server"
import { toNodeHandler } from "@modelcontextprotocol/node"
import {
createMcpExpressApp,
requireBearerAuth,
} from "@modelcontextprotocol/express"
import * as z from "zod"
import { getTicket, closeTicket } from "./tickets.js"
import { createApiKeyVerifier } from "./verifier.js"
const verifier = createApiKeyVerifier(process.env.TALOS_URL!)
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" })
app.all(
"/mcp",
requireBearerAuth({ verifier }),
(req, res) => void handler(req, res, req.body),
)
app
.listen(18480, "127.0.0.1")
.on("listening", () => console.log("MCP: http://127.0.0.1:18480/mcp"))
Check each tool's permissions
The tool handlers receive the verified permissions through authInfo. In the get_ticket handler, check tickets:read before
reading ticket data. Both handlers and their scope checks are highlighted in the complete server above.
The close_ticket handler checks tickets:write before changing the ticket. Your key has only tickets:read, so it can read a
ticket but receives Forbidden when it tries to close one.
Start the server
The complete source includes the imports, verifier configuration, and tool handlers shown above:
- Ory Network
- Self-hosted
Source: verifier.ts.
Source: server.ts.
In the server terminal, check and start the server:
npm run check
npm start
Source: verifier.ts.
Source: server.ts.
In the server terminal, check and start the server:
npm run check
TALOS_URL=http://127.0.0.1:14430 npm start
4. Connect your client
Keep the server running. Open another terminal window or tab for your MCP client. This is your client terminal. Change to the the selected example directory directory and load the application key:
export APPLICATION_API_KEY=$(jq -er '.secret' issued.credentials.json)
- Claude Code
- Codex
- MCP Inspector
Add the server and start a conversation. Claude Code saves the header in its local configuration:
claude mcp add --transport http ticket-tutorial http://127.0.0.1:18480/mcp --header "Authorization: Bearer $APPLICATION_API_KEY"
claude
Run /mcp to check the connection. See Claude Code's MCP documentation.
Add the server, referencing the environment variable, and start Codex from this terminal:
codex mcp add ticket-tutorial --url http://127.0.0.1:18480/mcp --bearer-token-env-var APPLICATION_API_KEY
codex
Run /mcp to check the connection. See Codex's MCP documentation.
MCP Inspector lets you call tools in your browser. Create its connection configuration and start it with temporary state:
umask 077
export APPLICATION_API_KEY=$(jq -er '.secret' issued.credentials.json)
jq -n --arg key "$APPLICATION_API_KEY" '{mcpServers: {tickets: {
type: "http", url: "http://127.0.0.1:18480/mcp",
headers: {Authorization: ("Bearer " + $key)}
}}}' > 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.
- Turn on the tickets connection switch.
- Open Tools.
5. Test permissions
Ask your agent to perform these actions with ticket-tutorial. In Inspector, execute each tool with id set to T-100:
| 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 |
The key has only tickets:read, so Forbidden means the server accepted the key but denied permission to close tickets. HTTP 401
means the key is invalid or expired; this key expires after one hour. HTTP 500 means the verifier failed to complete the check.
6. Revoke the key
Keep the server running and your client connected. Open another terminal window or tab to revoke the key while both remain active. Change to the the selected example directory directory:
- Ory Network
- Self-hosted
Replace YOUR_PROJECT_SLUG and paste the project API key at the prompt:
export ORY_SDK_URL='https://YOUR_PROJECT_SLUG.projects.oryapis.com'
printf 'Project API key (input hidden): '
read -r -s ORY_PROJECT_API_KEY
printf '\n'
export ORY_PROJECT_API_KEY
export KEY_ID=$(jq -er '.issued_api_key.key_id' issued.credentials.json)
curl -fsS -X POST "$ORY_SDK_URL"/v2alpha1/admin/issuedApiKeys/"$KEY_ID":revoke \
-H "Authorization: Bearer $ORY_PROJECT_API_KEY" \
-H 'Content-Type: application/json' -d '{"reason":"REVOCATION_REASON_SUPERSEDED"}'
export KEY_ID=$(jq -er '.issued_api_key.key_id' issued.credentials.json)
curl -fsS -X POST http://127.0.0.1:14430/v2alpha1/admin/issuedApiKeys/"$KEY_ID":revoke \
-H 'Content-Type: application/json' -d '{"reason":"REVOCATION_REASON_SUPERSEDED"}'
Read T-100 again in the existing connection. The request fails with HTTP 401: the server verifies every request, so an open
connection does not keep a revoked key's access.
If you use MCP Inspector, it may display Dynamic Client Registration rejected (HTTP 404) with Cannot POST /register after the
failed read. Inspector attempts OAuth registration after receiving HTTP 401, but this API-key server has no OAuth registration
endpoint. Open Inspector's Network panel and confirm that the POST request to /mcp returned 401 Unauthorized. This
confirms that the revoked key was rejected.
To test write access:
- In the terminal where you revoked the key, repeat Issue a read-only application key
with
"scopes": ["tickets:read", "tickets:write"]. - In the client terminal, stop Inspector or exit Claude Code or Codex and remove its
ticket-tutorialconnection. Then load the new key and reconnect by repeating Connect your client. - Ask your client to close
T-100and read it again. Its status is nowclosed. - In the terminal where you issued the new key, revoke it.
Clean up
- In the client terminal, exit your conversation or stop Inspector.
- In the client terminal, remove the connection with
claude mcp remove ticket-tutorialorcodex mcp remove ticket-tutorial. If you used Inspector, remove its state withrm -rf "$INSPECTOR_STATE"instead. - In the server terminal, stop the MCP server.
In the server terminal, delete local credentials:
- Ory Network
- Self-hosted
rm -f issued.credentials.json inspector.json project.json
unset APPLICATION_API_KEY KEY_ID ORY_PROJECT_API_KEY ORY_SDK_URL ORY_PROJECT_ID
Delete the project API key if you created it for this tutorial. See the complete example.
docker compose -f compose.yaml down -v
rm -f .env.talos issued.credentials.json inspector.json
unset APPLICATION_API_KEY KEY_ID
To deploy the verifier, see the deployment guide. See the complete example.
Repeat the unset command for your deployment in the client terminal and the terminal where you revoked the key. Each terminal
keeps its own variables.