Callbacks

Customize authentication behavior with lifecycle callbacks

Authentication 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.

Callback Execution Order

During authentication, callbacks execute in this specific order:

  1. user() - Resolves and upserts the user after OAuth/email verification
  2. jwt() - Customizes JWT token claims before signing
  3. session() - Shapes the final session object returned to the client

Execution Flow

OAuth/Email Verification → user()jwt() → Sign JWT → session() → Return Session

User Callback

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.

Signature

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;
};

Basic Example

Basic User Callback

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',
  }
);

Advanced Example with Provider Context

Provider-Specific User Handling

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,
    };
  },
}

User Callback Best Practices

  • Always return a user with a valid id and email
  • Use database transactions when creating related records (profiles, accounts, etc.)
  • Handle database errors gracefully (duplicates, constraints, etc.)
  • Keep the callback fast - avoid slow external API calls
  • Validate email addresses before creating users

Required Fields

The user callback must return an object with at least id and email fields. Missing these fields will cause authentication to fail.

JWT Callback

The jwt callback is invoked before signing the JWT token. Use it to add custom claims, embed roles/permissions, or modify token data.

Signature

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;

Basic Example

Adding Custom JWT Claims

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,
    };
  },
}

Advanced Example with Conditional Claims

Conditional JWT Claims

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(),
    };
  },
}

JWT Callback Best Practices

  • Keep tokens small - avoid embedding large data structures
  • Don't include sensitive data (passwords, API keys, etc.)
  • Use standard JWT claims (sub, iat, exp) when possible
  • Consider token size limits (4KB for cookies)
  • Avoid async database calls if possible - fetch data in the user callback instead

Token Size

JWT tokens are stored in cookies. Keep them under 4KB to avoid browser limits. For large datasets, store an ID in the token and fetch data on the server.

Session Callback

The 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.

Signature

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;

Basic Example

Adding Session Metadata

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(),
      },
    };
  },
}

Advanced Example with Client-Safe Data

Client-Safe Session Shaping

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,
    };
  },
}

Session Callback Best Practices

  • Remove sensitive fields before returning to the client
  • Add computed properties for UI convenience
  • Consider what data the frontend actually needs
  • Keep session objects serializable (no functions, classes, etc.)
  • Avoid slow async operations - the session is fetched on every request

Security Note

The session object is returned to the client (browser). Never include sensitive data like internal IDs, tokens, or privileged information.

Complete Example

Here's a complete example using all three callbacks together:

Complete Callback Configuration

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',
  }
);

Error Handling in Callbacks

Callbacks can throw errors to abort authentication:

Error Handling in Callbacks

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);
  },
}

Adapter Integration

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' }
);

Callback Priority

If both callbacks.user and adapter are provided, the callbacks.user takes priority. The adapter is only used for fallback.

Next Steps

Now that you understand callbacks, explore related topics:

Callbacks | @warpy-auth-sdk/core