MCP endpoints can be secured using Bearer token authentication. This guide shows how to:
.well-known/oauth-* endpoints that don't exist. Instead, use a "soft" approach that sets context when auth succeeds but allows requests to continue otherwise.If you're using Better Auth, you can leverage the built-in API Key plugin for a complete solution.
Add the API Key plugin to your Better Auth configuration:
import { betterAuth } from 'better-auth'
import { apiKey } from 'better-auth/plugins'
export const auth = betterAuth({
// ... your existing config
plugins: [
apiKey({
rateLimit: {
enabled: false, // Disable rate limiting (if not needed)
},
}),
],
})
Add the client plugin to use API key methods:
import { createAuthClient } from 'better-auth/client'
import { apiKeyClient } from 'better-auth/client/plugins'
const client = createAuthClient({
plugins: [
apiKeyClient(),
],
})
// Create an API key
const { data } = await client.apiKey.create({ name: 'My MCP Key' })
console.log(data.key) // Save this - only shown once!
// List API keys
const { data: keys } = await client.apiKey.list()
// Delete an API key
await client.apiKey.delete({ keyId: 'key-id' })
Create a helper function that validates API keys without throwing errors:
export async function getApiKeyUser(event: H3Event) {
const authHeader = getHeader(event, 'authorization')
if (!authHeader?.startsWith('Bearer ')) {
return null
}
const key = authHeader.slice(7)
const result = await auth.api.verifyApiKey({ body: { key } })
if (!result.valid || !result.key) {
return null
}
const user = await db.query.user.findFirst({
where: (users, { eq }) => eq(users.id, result.key!.userId),
})
if (!user) {
return null
}
return { user, apiKey: result.key }
}
Create a handler that sets user context when a valid API key is provided:
export default defineMcpHandler({
middleware: async (event) => {
const result = await getApiKeyUser(event)
if (result) {
event.context.user = result.user
event.context.userId = result.user.id
}
},
})
This approach:
event.context.user and event.context.userId when authentication succeedsundefined)Your tools can access the authenticated user from event.context. For tools that require authentication, check if the user exists:
export default defineMcpTool({
name: 'create_todo',
description: 'Create a new todo for the authenticated user',
inputSchema: {
title: z.string().describe('The title of the todo'),
content: z.string().optional().describe('Optional description or content'),
},
handler: async ({ title, content }) => {
const event = useEvent()
const userId = event.context.userId as string
if (!userId) {
return textResult('Authentication required. Please provide a valid API key.')
}
const [todo] = await db.insert(schema.todos).values({
title,
content: content || null,
userId,
createdAt: new Date(),
updatedAt: new Date(),
}).returning()
return textResult(`Todo created: ${todo.title}`)
},
})
For tools that work with or without authentication:
export default defineMcpTool({
name: 'list_todos',
description: 'List all todos for the authenticated user',
inputSchema: {},
handler: async () => {
const event = useEvent()
const userId = event.context.userId as string
if (!userId) {
return textResult('Authentication required. Please provide a valid API key.')
}
const todos = await db.query.todos.findMany({
where: (todos, { eq }) => eq(todos.userId, userId),
})
return textResult(JSON.stringify(todos, null, 2))
},
})
asyncContext in your Nuxt config to use useEvent():export default defineNuxtConfig({
nitro: {
experimental: {
asyncContext: true,
},
},
})
If you're not using Better Auth, you can implement your own token validation. Remember to use a soft approach that doesn't throw errors:
import { createHash } from 'node:crypto'
export async function getTokenUser(event: H3Event) {
const authHeader = getHeader(event, 'authorization')
if (!authHeader?.startsWith('Bearer ')) {
return null
}
const token = authHeader.slice(7)
const tokenHash = createHash('sha256').update(token).digest('hex')
// Look up the token in your database
const apiToken = await db.query.apiTokens.findFirst({
where: (tokens, { eq }) => eq(tokens.hash, tokenHash),
})
if (!apiToken) {
return null
}
// Check expiration
if (apiToken.expiresAt && apiToken.expiresAt < new Date()) {
return null
}
return { userId: apiToken.userId }
}
export default defineMcpHandler({
middleware: async (event) => {
const result = await getTokenUser(event)
if (result) {
event.context.userId = result.userId
}
},
})
Add your MCP server to .cursor/mcp.json:
{
"mcpServers": {
"my-app": {
"url": "http://localhost:3000/mcp",
"headers": {
"Authorization": "Bearer your-api-key-here"
}
}
}
}
Add to your Claude Desktop configuration:
{
"mcpServers": {
"my-app": {
"url": "http://localhost:3000/mcp",
"headers": {
"Authorization": "Bearer your-api-key-here"
}
}
}
}
Most MCP clients support custom headers. Check your client's documentation for the exact configuration format.
For type-safe context, extend the H3 event context:
declare module 'h3' {
interface H3EventContext {
user?: {
id: string
name: string
email: string
}
userId?: string
}
}