Customize authentication behavior with lifecycle callbacks
The @warpy-auth-sdk/core provides three powerful callbacks that let you customize the authentication flow:user, jwt, and session. These callbacks execute at different points in the authentication lifecycle, giving you full control over user resolution, token claims, and session data.
During authentication, callbacks execute in this specific order:
OAuth/Email Verification → user() → jwt() → Sign JWT → session() → Return Session
The user callback is invoked after successful OAuth or email verification. It's your opportunity to resolve the user from your database, create new users, or map external profiles to your user model.
type UserCallback = (
user: {
id?: string;
email: string;
name?: string;
picture?: string;
},
context?: {
provider?: string;
}
) => Promise<{
id: string;
email: string;
name?: string;
picture?: string;
}> | {
id: string;
email: string;
name?: string;
picture?: string;
};Simple user resolution with database lookup
import { authMiddleware } from '@warpy-auth-sdk/core/next';
import { google } from '@warpy-auth-sdk/core';
import { prisma } from '@/lib/db';
const handler = authMiddleware(
{
secret: process.env.AUTH_SECRET!,
provider: google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: process.env.GOOGLE_REDIRECT_URI!,
}),
callbacks: {
async user(oauthUser, context) {
// Find existing user by email
let user = await prisma.user.findUnique({
where: { email: oauthUser.email },
});
// Create new user if not found
if (!user) {
user = await prisma.user.create({
data: {
email: oauthUser.email,
name: oauthUser.name,
picture: oauthUser.picture,
},
});
}
// Return user object for session
return {
id: user.id,
email: user.email,
name: user.name ?? undefined,
picture: user.picture ?? undefined,
};
},
},
},
{
basePath: '/api/auth',
successRedirect: '/dashboard',
}
);Different logic based on authentication provider
callbacks: {
async user(oauthUser, context) {
// Different handling for different providers
if (context?.provider === 'oauth') {
// OAuth providers (Google, GitHub, etc.)
return await upsertOAuthUser(oauthUser);
} else if (context?.provider === 'email') {
// Email magic link
return await upsertEmailUser(oauthUser.email);
} else if (context?.provider === 'twofa') {
// Two-factor authentication
return await upsertTwoFactorUser(oauthUser.email);
}
// Fallback
return {
id: oauthUser.email,
email: oauthUser.email,
name: oauthUser.name,
};
},
}id and emailid and email fields. Missing these fields will cause authentication to fail.The jwt callback is invoked before signing the JWT token. Use it to add custom claims, embed roles/permissions, or modify token data.
type JWTCallback = (
token: {
userId: string;
email?: string;
name?: string;
type?: 'standard' | 'mcp-agent';
scopes?: string[];
agentId?: string;
[key: string]: any; // Custom claims
}
) => Promise<JWTPayload> | JWTPayload;Embed user roles and permissions in the token
import { getUserRole } from '@/lib/auth';
callbacks: {
async jwt(token) {
// Fetch user role from database
const role = await getUserRole(token.userId);
// Add custom claims
return {
...token,
role: role.name,
permissions: role.permissions,
tenantId: role.tenantId,
};
},
}Add different claims based on user type
callbacks: {
async jwt(token) {
// Don't modify MCP agent tokens
if (token.type === 'mcp-agent') {
return token;
}
// Fetch user from database
const user = await prisma.user.findUnique({
where: { id: token.userId },
include: { organization: true, roles: true },
});
if (!user) {
throw new Error('User not found');
}
// Add organization and role claims
return {
...token,
organizationId: user.organization?.id,
roles: user.roles.map(r => r.name),
isAdmin: user.roles.some(r => r.name === 'admin'),
// Add timestamp for debugging
issuedAt: new Date().toISOString(),
};
},
}sub, iat, exp) when possibleThe session callback is invoked after JWT signing to shape the final session object. Use it to add computed properties, format data, or control what's exposed to the client.
type SessionCallback = (
session: {
user: {
id: string;
email: string;
name?: string;
picture?: string;
};
expires: Date;
token?: string;
type?: 'standard' | 'mcp-agent';
scopes?: string[];
agentId?: string;
}
) => Promise<Session> | Session;Enhance session with computed properties
callbacks: {
session(session) {
// Add computed properties
return {
...session,
isAuthenticated: true,
expiresIn: Math.floor(
(session.expires.getTime() - Date.now()) / 1000
),
user: {
...session.user,
initials: session.user.name
?.split(' ')
.map(n => n[0])
.join('')
.toUpperCase(),
},
};
},
}Control what data is exposed to the client
callbacks: {
async session(session) {
// Decode JWT to access custom claims
const decoded = jwt.decode(session.token!) as any;
// Shape client-safe session
return {
user: {
id: session.user.id,
email: session.user.email,
name: session.user.name,
picture: session.user.picture,
// Add role from JWT claims
role: decoded.role,
// Add computed display name
displayName: session.user.name || session.user.email.split('@')[0],
},
expires: session.expires,
// Don't expose raw token to client
// token: session.token, // Commented out
type: session.type,
};
},
}Here's a complete example using all three callbacks together:
Full authentication customization with all callbacks
import { authMiddleware } from '@warpy-auth-sdk/core/next';
import { google } from '@warpy-auth-sdk/core';
import { prisma } from '@/lib/db';
import jwt from 'jsonwebtoken';
const handler = authMiddleware(
{
secret: process.env.AUTH_SECRET!,
provider: google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: process.env.GOOGLE_REDIRECT_URI!,
}),
callbacks: {
// Step 1: Resolve/upsert user
async user(oauthUser) {
let user = await prisma.user.findUnique({
where: { email: oauthUser.email },
include: { roles: true },
});
if (!user) {
user = await prisma.user.create({
data: {
email: oauthUser.email,
name: oauthUser.name,
picture: oauthUser.picture,
roles: {
connect: { name: 'user' }, // Default role
},
},
include: { roles: true },
});
}
return {
id: user.id,
email: user.email,
name: user.name ?? undefined,
picture: user.picture ?? undefined,
};
},
// Step 2: Add custom JWT claims
async jwt(token) {
// Skip for MCP agents
if (token.type === 'mcp-agent') return token;
// Fetch user roles
const user = await prisma.user.findUnique({
where: { id: token.userId },
include: { roles: true, organization: true },
});
return {
...token,
roles: user?.roles.map(r => r.name) ?? [],
organizationId: user?.organization?.id,
};
},
// Step 3: Shape final session
session(session) {
// Decode JWT to access custom claims
const decoded = jwt.decode(session.token!) as any;
return {
user: {
id: session.user.id,
email: session.user.email,
name: session.user.name,
picture: session.user.picture,
roles: decoded.roles ?? [],
organizationId: decoded.organizationId,
},
expires: session.expires,
type: session.type,
};
},
},
},
{
basePath: '/api/auth',
successRedirect: '/dashboard',
errorRedirect: '/login',
}
);Callbacks can throw errors to abort authentication:
Validate and abort authentication when needed
callbacks: {
async user(oauthUser) {
// Check if user is banned
const bannedUser = await prisma.bannedUser.findUnique({
where: { email: oauthUser.email },
});
if (bannedUser) {
throw new Error('Account has been suspended');
}
// Check domain whitelist
const allowedDomains = ['company.com', 'partner.com'];
const domain = oauthUser.email.split('@')[1];
if (!allowedDomains.includes(domain)) {
throw new Error(`Email domain ${domain} is not allowed`);
}
// Continue with user creation/lookup
return await upsertUser(oauthUser);
},
}If you don't provide a user callback, the SDK will use the adapter's default user resolution logic:
import { authMiddleware } from '@warpy-auth-sdk/core/next';
import { google } from '@warpy-auth-sdk/core';
import { PrismaAdapter } from '@warpy-auth-sdk/core/adapters/prisma';
import { prisma } from '@/lib/db';
const handler = authMiddleware(
{
secret: process.env.AUTH_SECRET!,
provider: google({ /* ... */ }),
// No user callback - adapter handles it
adapter: PrismaAdapter(prisma),
callbacks: {
// Only customize JWT and session
jwt: (token) => ({ ...token, custom: 'claim' }),
session: (session) => session,
},
},
{ basePath: '/api/auth' }
);callbacks.user and adapter are provided, the callbacks.user takes priority. The adapter is only used for fallback.Now that you understand callbacks, explore related topics: