Guide · Auth & Identity
MCP Tools for NextAuth / Auth.js — session vs JWT callbacks, credentials provider, database vs JWT sessions
Three NextAuth / Auth.js behaviours catch developers building MCP auth integrations: data added in the jwt callback is NOT automatically visible in getServerSession() — you must explicitly copy the data from the token parameter to the session object inside the session callback, because session callbacks run on every getServerSession() call and receive the JWT's current payload, but only expose what you explicitly return; the custom credentials provider bypasses the OAuth security model entirely — no PKCE, no state parameter, no redirect URI validation, no email verification — meaning credentials-based auth depends entirely on your authorize() function's correctness, and any vulnerability there becomes a direct authentication bypass; and database session strategy queries your database on every getServerSession() call — unlike JWT sessions that are verified locally, database sessions perform a SELECT on the sessions table on every request, which is a hidden latency cost in MCP tool handlers that call getServerSession() to identify the user.
TL;DR
Add data to JWT: return it from jwt callback. Expose it to client: copy it in session callback. Check session in MCP handler: const session = await auth() (Auth.js v5) or await getServerSession(authOptions) (NextAuth v4). Credentials provider: only use when OAuth is impossible. Database vs JWT sessions: JWT is stateless (faster, unrevokable), database is revokable (slower, requires DB).
JWT and session callbacks — execution order and data flow
The two most important NextAuth callbacks for customising session data are jwt and session. They run at different times and serve different purposes. Confusing their execution model is the leading cause of "I added a field but it doesn't appear in getServerSession()" bugs.
// NextAuth v4 configuration (pages/api/auth/[...nextauth].ts)
import NextAuth from 'next-auth';
import type { NextAuthOptions } from 'next-auth';
import GitHub from 'next-auth/providers/github';
export const authOptions: NextAuthOptions = {
providers: [GitHub({ clientId: '...', clientSecret: '...' })],
session: { strategy: 'jwt' }, // or 'database'
callbacks: {
// jwt callback fires:
// 1. On initial sign-in (account + profile + user are available)
// 2. On every subsequent request that reads the session (account/profile/user undefined)
// 3. On token refresh (if using refresh tokens)
async jwt({ token, user, account, profile }) {
if (user) {
// Initial sign-in only — copy fields you want persisted into the token
token.userId = user.id;
token.role = (user as { role?: string }).role ?? 'member';
}
if (account) {
// Initial sign-in only — persist the OAuth provider's access token if needed
token.providerAccessToken = account.access_token;
token.providerAccessTokenExpiry = account.expires_at;
}
return token; // This token is encrypted and stored in the session cookie
},
// session callback fires:
// On every call to getServerSession() / useSession() / auth()
// Receives the current 'token' from the jwt callback
// The 'session' parameter contains the DEFAULT session shape — NOT the token
// You must manually copy from token → session to expose data to callers
async session({ session, token }) {
// WRONG: session.userId is not populated automatically from token.userId
// RIGHT: explicitly copy what callers need
session.user.id = token.userId as string;
session.user.role = token.role as string;
// Do NOT expose token.providerAccessToken in session.user —
// the session object is returned to the client (useSession), exposing tokens
// Store sensitive token fields in token only; access them server-side via getToken()
return session; // This is what getServerSession() returns
},
},
};
// Auth.js v5 (auth.ts for App Router)
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
export const { auth, handlers, signIn, signOut } = NextAuth({
providers: [GitHub],
callbacks: {
async jwt({ token, user }) {
if (user) token.role = (user as { role?: string }).role;
return token;
},
async session({ session, token }) {
session.user.role = token.role as string;
return session;
},
},
});
// MCP tool handler — Auth.js v5
export async function handleTool(params: Record<string, unknown>) {
const session = await auth(); // Server-side, no DB query (JWT strategy)
if (!session?.user?.id) throw new Error('Unauthenticated');
return performAction(session.user.id, session.user.role, params);
}
TypeScript users must extend the NextAuth module types to avoid session.user.id being reported as never. Declare a module augmentation for next-auth to add your custom fields to Session, User, and JWT. Without this, TypeScript compiles but your editor will flag access to custom session fields as type errors, which often leads developers to use as any casts that hide genuine bugs.
Credentials provider — security model and safe patterns
The credentials provider exists for username/password authentication and other custom authentication schemes. It integrates with NextAuth's session management but skips all of the CSRF, PKCE, and redirect URI protections that OAuth providers provide. Use it only when OAuth is genuinely unavailable (e.g., validating API keys or internal service-to-service auth).
import CredentialsProvider from 'next-auth/providers/credentials';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
const credentialsSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
});
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: 'Email and password',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
// STEP 1: Validate input shape before touching the database
const parsed = credentialsSchema.safeParse(credentials);
if (!parsed.success) return null; // returning null → sign-in fails
const { email, password } = parsed.data;
// STEP 2: Fetch user from DB
const user = await db.users.findByEmail(email);
if (!user) {
// Use a constant-time comparison to avoid timing oracle attacks
// even when no user exists — bcrypt.compare against a dummy hash
await bcrypt.compare(password, '$2b$10$dummyhashtopreventtimingattack.');
return null;
}
// STEP 3: Verify password with constant-time comparison
const passwordMatch = await bcrypt.compare(password, user.hashedPassword);
if (!passwordMatch) return null;
// STEP 4: Return the User object — shape must match NextAuth's User type
return {
id: user.id,
email: user.email,
name: user.displayName,
role: user.role,
// Do NOT return the hashed password or sensitive fields here
};
},
}),
],
// Credentials provider REQUIRES JWT session strategy
// Database sessions don't work with credentials provider by default
// because the provider doesn't receive account/profile objects for DB linking
session: { strategy: 'jwt' },
// signIn callback for additional checks after authorize() returns a user
callbacks: {
async signIn({ user, account }) {
if (account?.provider === 'credentials') {
// Additional checks: email verified, account active, etc.
const dbUser = await db.users.findById(user.id);
if (!dbUser?.emailVerified) {
return '/auth/verify-email?error=not-verified'; // redirect to custom error page
}
}
return true; // allow sign-in
},
},
};
The credentials provider does not emit an account object in the jwt callback's initial sign-in call — account is null for credentials-based sign-ins. This means any if (account) { ... } guard in your jwt callback that persists OAuth provider tokens will correctly skip credentials sign-ins. However, user is still populated on initial sign-in — the object returned from authorize() is passed as user.
Database vs JWT session strategy — trade-offs for MCP
NextAuth supports two session storage strategies. The choice affects your MCP server's latency, revocation capability, and infrastructure requirements in ways that compound with agent workloads.
// DATABASE STRATEGY
// Sessions stored in DB table; session cookie contains an opaque token
// getServerSession() queries DB on every call
// ADVANTAGES: instant revocation (delete row), no token size limit, auditable
// DISADVANTAGES: DB query per MCP tool invocation, requires session adapter
// JWT STRATEGY
// Sessions encoded in a signed, encrypted cookie (no DB)
// getServerSession() verifies the cookie locally (no network call)
// ADVANTAGES: zero-latency verification, no DB dependency, scales horizontally
// DISADVANTAGES: cannot revoke without a token denylist, token size limited by cookie (4KB)
// Latency comparison for a typical MCP tool handler:
// JWT: ~0ms session verification + tool work
// Database: ~5-20ms DB round-trip + tool work (adds up across many tool calls)
// For MCP servers: JWT strategy is almost always preferable
// Unless you need:
// - Immediate session revocation on logout/security event
// - Session introspection from outside the Next.js app
// - Storing more than ~1KB of session data
// Database adapter setup (Prisma example):
import { PrismaAdapter } from '@auth/prisma-adapter';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
session: { strategy: 'database', maxAge: 30 * 24 * 60 * 60 }, // 30 days
// With database strategy, getServerSession() makes a DB query:
// SELECT * FROM sessions WHERE sessionToken = $1 AND expires > NOW()
// Result includes the user via JOIN — no separate user lookup needed
};
// Accessing the raw JWT (JWT strategy only) — useful for inter-service calls
// nextauth/jwt exposes getToken() which reads the cookie without DB query
import { getToken } from 'next-auth/jwt';
// In API routes or middleware:
async function getMcpCallerToken(req: NextApiRequest): Promise<JWT | null> {
return getToken({
req,
secret: process.env.NEXTAUTH_SECRET!,
// raw: true — return the encoded JWT string instead of decoded payload
});
}
// Use the raw token for inter-service authentication (pass to another MCP server)
async function callDownstreamMcp(req: NextApiRequest, toolParams: unknown) {
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET!, raw: true });
// Note: this passes a NextAuth-signed JWT, not a provider access token
// The downstream service must also use NextAuth-compatible verification
return fetch('https://downstream-mcp/tool', {
headers: { Authorization: `Bearer ${token}` },
method: 'POST',
body: JSON.stringify(toolParams),
});
}
Mixing session strategies in the same application (e.g., JWT strategy in one route, database adapter for user/account persistence) is supported — NextAuth stores users and accounts in the DB even when sessions are JWT-based. The adapter is required for OAuth provider user/account linking even when sessions are stored in JWT cookies. The session.strategy only affects session storage, not user or account storage.
Provider profile mapping — field names differ per provider
Each OAuth provider returns a different shape in its profile response. NextAuth normalises these to a User shape, but the mapping is lossy — fields that don't exist in NextAuth's User type are dropped unless you extract them in the jwt callback's profile parameter.
// Provider-specific profile shapes (what you receive in jwt({..., profile}))
// GitHub: { login, id, avatar_url, name, email, bio, company, ... }
// Google: { sub, name, email, picture, email_verified (string "true" not boolean), ... }
// Discord: { id, username, discriminator, avatar, email, verified, ... }
// Azure AD: { sub, oid, name, email, unique_name, preferred_username, roles, ... }
// Extract provider-specific fields in the jwt callback
async function jwt({ token, user, account, profile }) {
if (profile) {
// GitHub-specific: 'login' is the username, 'id' is the numeric GitHub user ID
if (account?.provider === 'github') {
token.githubLogin = (profile as { login: string }).login;
token.githubId = (profile as { id: number }).id;
}
// Google-specific: email_verified is a string "true", not a boolean
if (account?.provider === 'google') {
const googleProfile = profile as { email_verified?: string | boolean };
token.emailVerified =
googleProfile.email_verified === true ||
googleProfile.email_verified === 'true';
}
// Azure AD: roles claim maps to array of app role assignments
if (account?.provider === 'azure-ad') {
token.azureRoles = (profile as { roles?: string[] }).roles ?? [];
}
}
return token;
}
// Custom profile() function in provider configuration (alternative approach)
// Providers accept an optional 'profile' function to map IdP response to User
const GitHubProvider = GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
profile(profile) {
return {
id: String(profile.id), // NextAuth expects string, GitHub returns number
name: profile.name ?? profile.login,
email: profile.email, // May be null if user has private email on GitHub
image: profile.avatar_url, // NextAuth uses 'image', GitHub uses 'avatar_url'
// Add custom fields (requires type augmentation to access later)
login: profile.login,
};
},
});
GitHub users may have email: null in the OAuth profile if they've set their email to private in GitHub settings. NextAuth's User type marks email as optional, so this is expected — but downstream code that assumes session.user.email is always a string will fail silently or throw. Always handle null email in profile mappings, especially for GitHub and Twitter/X providers.
Protecting MCP routes — middleware and server components
Auth.js v5 provides an auth function that works in middleware, server components, server actions, and API routes. NextAuth v4 provides getServerSession for server-side use and withAuth for middleware. The patterns differ but the goal is the same: reject unauthenticated requests at the MCP server boundary.
// Auth.js v5 — middleware (middleware.ts at root of Next.js project)
import { auth } from './auth';
export default auth((req) => {
// req.auth is the session or null
const isAuthenticated = !!req.auth;
const isMcpRoute = req.nextUrl.pathname.startsWith('/api/mcp/');
if (isMcpRoute && !isAuthenticated) {
// Return 401 for API routes (not a redirect — clients expect JSON, not HTML)
return Response.json({ error: 'Unauthenticated' }, { status: 401 });
}
});
export const config = {
matcher: ['/api/mcp/:path*', '/dashboard/:path*'],
};
// Auth.js v5 — API route
import { auth } from '@/auth';
export const POST = auth(async (req) => {
// req.auth is the session; null if unauthenticated
if (!req.auth) {
return Response.json({ error: 'Unauthenticated' }, { status: 401 });
}
const userId = req.auth.user?.id;
// ... handle MCP tool call
});
// NextAuth v4 — API route
import { getServerSession } from 'next-auth';
import { authOptions } from '@/pages/api/auth/[...nextauth]';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).json({ error: 'Unauthenticated' });
}
// session.user.id is populated only if you set it in the session callback
const userId = session.user.id;
// ...
}
// Protecting MCP tools that use bearer tokens (non-browser clients)
// NextAuth does not natively support bearer token auth — the session is cookie-based
// For API clients that send Authorization: Bearer <token>:
// Option A: issue a separate API key and validate outside NextAuth
// Option B: use getToken() with custom secret to read a NextAuth JWT from the Authorization header
import { getToken } from 'next-auth/jwt';
async function verifyBearerSession(req: Request): Promise<JWT | null> {
// next-auth/jwt doesn't support Authorization headers natively in App Router
// Workaround: create a synthetic request with cookie header from Authorization
const authHeader = req.headers.get('Authorization') ?? '';
if (!authHeader.startsWith('Bearer ')) return null;
const tokenString = authHeader.slice(7);
// Decode and verify manually using NextAuth's NEXTAUTH_SECRET
// This pattern is for internal service-to-service MCP calls, not for end users
// End-user MCP clients should use cookie-based sessions or OAuth PKCE
return null; // Implement per your architecture
}
NextAuth's session cookies are HTTP-only and SameSite=Lax by default, which prevents cross-site request forgery for cookie-based flows. MCP clients that are not browser-based (e.g., CLI tools, other MCP servers calling yours as a downstream tool) cannot send cookies. For these callers, implement a separate bearer token validation path using API keys or service-to-service JWT issuance outside of NextAuth.
Health probe — NextAuth session endpoint availability
NextAuth exposes session information via GET /api/auth/session. An MCP server that depends on NextAuth for authentication should verify this endpoint is responding correctly. A misconfigured NEXTAUTH_SECRET or broken database adapter causes all session reads to return null without throwing an error — users appear as unauthenticated silently.
async function checkNextAuthHealth(baseUrl: string): Promise<{
healthy: boolean;
sessionEndpointReachable: boolean;
error?: string;
}> {
try {
// GET /api/auth/session returns {} for unauthenticated requests (not an error)
// A 200 response means NextAuth is configured and the endpoint is reachable
const res = await fetch(`${baseUrl}/api/auth/session`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
return {
healthy: false,
sessionEndpointReachable: false,
error: `Session endpoint returned HTTP ${res.status}`,
};
}
// 200 with {} body means NextAuth is up but caller is unauthenticated — healthy
// 200 with { user: {...}, expires: "..." } means authenticated session
return { healthy: true, sessionEndpointReachable: true };
} catch (err) {
return {
healthy: false,
sessionEndpointReachable: false,
error: String(err),
};
}
}
// For database strategy: also probe the DB adapter
// A broken database connection causes session reads to silently return null
async function checkSessionDatabaseHealth(prisma: PrismaClient): Promise<boolean> {
try {
await prisma.$queryRaw`SELECT 1`;
return true;
} catch {
return false;
}
}
AliveMCP monitors NextAuth-backed MCP servers by probing the /api/auth/session endpoint and watching for authenticated-session expiry patterns — a sudden drop in valid session responses indicates that NEXTAUTH_SECRET was rotated without invalidating existing sessions, or that the session database has been truncated.
Related guides
- MCP Tools for Clerk — session tokens, JWT templates, webhook verification
- MCP Tools for WorkOS — JWKS rotation, AuthKit, M2M API keys, Directory Sync
- MCP Tools for Keycloak — realm config, offline tokens, role claim mappers
- MCP Tools for Firebase Auth — ID tokens, custom tokens, session cookies
- MCP server authentication overview
- MCP server OAuth patterns