Authentication
Overview
MCP endpoints can be secured using Bearer token authentication. This guide shows how to:
- Generate and manage API keys for users
- Validate tokens in MCP middleware
- Access user context in your tools
- Configure MCP clients with authentication
.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.Using Better Auth API Keys
If you're using Better Auth, you can leverage the built-in API Key plugin for a complete solution.
Server Configuration
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)
},
}),
],
})
Client Configuration
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' })
Helper Function
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 }
}
MCP Handler with Authentication
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:
- Sets
event.context.userandevent.context.userIdwhen authentication succeeds - Leaves context as
undefinedwhen no valid token is provided - Tools must check for user context and return an error if not authenticated
Using Context in Tools
Your 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,
},
},
})
Custom Token Validation
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
}
},
})
Configuring MCP Clients
Cursor
Add your MCP server to .cursor/mcp.json:
{
"mcpServers": {
"my-app": {
"url": "http://localhost:3000/mcp",
"headers": {
"Authorization": "Bearer your-api-key-here"
}
}
}
}
Claude Desktop
Add to your Claude Desktop configuration:
{
"mcpServers": {
"my-app": {
"url": "http://localhost:3000/mcp",
"headers": {
"Authorization": "Bearer your-api-key-here"
}
}
}
}
Other Clients
Most MCP clients support custom headers. Check your client's documentation for the exact configuration format.
TypeScript
For type-safe context, extend the H3 event context:
declare module 'h3' {
interface H3EventContext {
user?: {
id: string
name: string
email: string
}
userId?: string
}
}
Security Best Practices
- Always hash tokens - Store hashed tokens in your database, not plaintext
- Set expiration dates - API keys should expire to limit exposure
- Implement rate limiting - Prevent abuse with request limits per key
- Allow key revocation - Users should be able to delete compromised keys
- Log key usage - Track when keys are used for security auditing
Next Steps
- Middleware - Learn more about middleware options
- Handlers - Create custom authenticated handlers
- TypeScript - Type-safe context definitions