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

Skip to main content

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:

View the complete example.

cd code-examples/mcp-auth/typescript/api-keys-network
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​

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.

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

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:

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)

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:

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:

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

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:

server.ts
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"))

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:

Source: verifier.ts.

Source: server.ts.

In the server terminal, check and start the server:

npm run check
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)

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.

5. Test permissions​

Ask your agent to perform these actions with ticket-tutorial. In Inspector, execute each tool with id set to T-100:

ActionToolExpected result
Read ticket T-100.get_ticketopen
Close ticket T-100.close_ticketForbidden
Read ticket T-100 again.get_ticketStill 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:

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"}'

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:

  1. In the terminal where you revoked the key, repeat Issue a read-only application key with "scopes": ["tickets:read", "tickets:write"].
  2. In the client terminal, stop Inspector or exit Claude Code or Codex and remove its ticket-tutorial connection. Then load the new key and reconnect by repeating Connect your client.
  3. Ask your client to close T-100 and read it again. Its status is now closed.
  4. In the terminal where you issued the new key, revoke it.

Clean up​

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

In the server terminal, delete local credentials:

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.

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.