Introduction
Callbacks are hooks into the authentication flow. They let you customize what happens when users sign in, when sessions are created, and what data gets stored in tokens.
Key Concepts
Callbacks are functions called at specific points in the auth flow:
signIn: Called when a user attempts to sign inredirect: Called when redirecting after sign in/outjwt: Called when a JWT is created or updatedsession: Called when session is checked
Real World Context
Callbacks enable:
- Restricting sign-ups to specific email domains
- Adding custom data to sessions (role, organization)
- Logging authentication events
- Blocking banned users
Deep Dive
The signIn Callback
Control who can sign in:
typescriptexport const { handlers, auth } = NextAuth({ callbacks: { async signIn({ user, account, profile }) { // Only allow company emails if (user.email?.endsWith('@company.com')) { return true; } // Block sign-in return false; // Or redirect to an error page return '/auth/error?error=AccessDenied'; }, }, });
The jwt Callback
Customize the JWT token:
typescriptcallbacks: { async jwt({ token, user, account, trigger }) { // Initial sign in if (user) { token.id = user.id; token.role = user.role || 'user'; } // Update session from client (useSession().update()) if (trigger === 'update') { const freshUser = await getUserById(token.id as string); token.role = freshUser.role; } return token; }, }
The session Callback
Customize what's available in the session:
typescriptcallbacks: { async session({ session, token }) { // Expose token data to the session if (token) { session.user.id = token.id as string; session.user.role = token.role as string; } return session; }, }
The redirect Callback
Control post-auth redirects:
typescriptcallbacks: { async redirect({ url, baseUrl }) { // Redirect to the URL they were trying to access if (url.startsWith('/')) return `${baseUrl}${url}`; // Allow redirects to same origin if (new URL(url).origin === baseUrl) return url; // Default to home return baseUrl; }, }
Complete Example with All Callbacks
typescript// auth.ts import NextAuth from 'next-auth'; import GitHub from 'next-auth/providers/github'; import { prisma } from '@/lib/prisma'; export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [GitHub], callbacks: { async signIn({ user, account }) { // Check if user is banned const dbUser = await prisma.user.findUnique({ where: { email: user.email! }, }); if (dbUser?.banned) { return '/auth/banned'; } // Log sign-in await prisma.authLog.create({ data: { email: user.email!, provider: account?.provider, action: 'sign_in', }, }); return true; }, async jwt({ token, user }) { if (user) { // First sign in - fetch or create user const dbUser = await prisma.user.upsert({ where: { email: user.email! }, update: { lastLogin: new Date() }, create: { email: user.email!, name: user.name, image: user.image, role: 'user', }, }); token.id = dbUser.id; token.role = dbUser.role; } return token; }, async session({ session, token }) { session.user.id = token.id as string; session.user.role = token.role as string; return session; }, }, });
TypeScript Types
Extend the types to include custom fields:
typescript// types/next-auth.d.ts import { DefaultSession } from 'next-auth'; declare module 'next-auth' { interface Session { user: { id: string; role: string; } & DefaultSession['user']; } }
Common Pitfalls
-
Async operations in callbacks: Keep them fast—they run on every request. Cache or optimize database calls.
-
Forgetting to return: All callbacks must return a value.
signInreturns boolean/string, others return their modified parameter. -
jwt vs session confusion:
jwtruns first and populates the token.sessionruns second and populates the session from the token.
Best Practices
- Keep callbacks lean: Heavy operations slow down every auth check
- Use database sessions for heavy data: If you need lots of user data, use database sessions instead of JWTs
- Type your extensions: Add TypeScript declarations for custom session fields
- Log important events: Track sign-ins, failures, and suspicious activity
Summary
Callbacks customize the Auth.js flow: signIn controls access, jwt customizes the token, session shapes what's exposed to your app, and redirect controls navigation. Use them to add custom data, restrict access, and log authentication events. Keep them fast and always return the expected value.