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 when no valid token is providedYour tools can access the authenticated user from event.context. Always check if the user exists and return an error message when not authenticated:
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}`)
},
})
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
}
}