Skip to main content
While the standard authentication flow (handleAuth()) handles most use cases automatically, some scenarios require manually creating and storing sessions. This is useful for custom authentication flows that don’t follow the standard OAuth redirect pattern.

When to use custom flows

Use custom authentication flows when:
  • Implementing passwordless authentication with email verification codes
  • Building magic link authentication
  • Integrating with custom identity providers
  • Implementing token exchange flows
  • Using self-hosted AuthKit with custom authentication logic
Custom flows are an advanced API intended for specific integration scenarios. If you’re using hosted AuthKit with the standard OAuth flow, you should not need this.

The saveSession function

The saveSession() function manually creates and encrypts a session cookie:
import { saveSession } from '@workos-inc/authkit-nextjs';

await saveSession(
  {
    accessToken: '...',
    refreshToken: '...',
    user: { id: '...', email: '...', ... },
    impersonator: undefined,
  },
  request // NextRequest or URL string
);

Function signature

saveSession(
  sessionOrResponse: Session | AuthenticationResponse,
  request: NextRequest | string
): Promise<void>
Parameters:
  • sessionOrResponse - Session object or WorkOS AuthenticationResponse containing:
    • accessToken - JWT access token
    • refreshToken - Refresh token for session renewal
    • user - User object from WorkOS
    • impersonator - (Optional) Impersonator info if user is being impersonated
  • request - Either a NextRequest object or URL string for cookie domain/secure settings

Email verification flow

Implement a passwordless email verification flow:
// app/api/auth/verify-email/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getWorkOS } from '@workos-inc/authkit-nextjs';
import { saveSession } from '@workos-inc/authkit-nextjs';

export async function POST(req: NextRequest) {
  try {
    const { code } = await req.json();

    if (!code) {
      return NextResponse.json(
        { error: 'Verification code is required' },
        { status: 400 }
      );
    }

    // Authenticate with the verification code
    const authResponse = await getWorkOS().userManagement.authenticateWithEmailVerification({
      clientId: process.env.WORKOS_CLIENT_ID!,
      code,
    });

    // Save the session to a cookie
    await saveSession(authResponse, req);

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('Email verification failed:', error);
    return NextResponse.json(
      { error: 'Invalid or expired verification code' },
      { status: 400 }
    );
  }
}
Client-side implementation:
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';

export function EmailVerificationForm() {
  const [code, setCode] = useState('');
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError(null);

    try {
      const response = await fetch('/api/auth/verify-email', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ code }),
      });

      if (!response.ok) {
        const data = await response.json();
        throw new Error(data.error || 'Verification failed');
      }

      // Session is now saved, redirect to dashboard
      router.push('/dashboard');
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Verification failed');
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={code}
        onChange={(e) => setCode(e.target.value)}
        placeholder="Enter verification code"
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Verifying...' : 'Verify'}
      </button>
      {error && <div className="error">{error}</div>}
    </form>
  );
}
Implement authentication via magic links:
// app/api/auth/magic-link/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getWorkOS } from '@workos-inc/authkit-nextjs';
import { saveSession } from '@workos-inc/authkit-nextjs';

export async function GET(req: NextRequest) {
  try {
    const { searchParams } = new URL(req.url);
    const token = searchParams.get('token');

    if (!token) {
      return NextResponse.redirect(new URL('/login?error=invalid_token', req.url));
    }

    // Exchange the magic link token for a session
    const authResponse = await getWorkOS().userManagement.authenticateWithMagicAuth({
      clientId: process.env.WORKOS_CLIENT_ID!,
      code: token,
    });

    // Save the session
    await saveSession(authResponse, req);

    // Redirect to the intended destination
    const returnTo = searchParams.get('return_to') || '/dashboard';
    return NextResponse.redirect(new URL(returnTo, req.url));
  } catch (error) {
    console.error('Magic link authentication failed:', error);
    return NextResponse.redirect(new URL('/login?error=invalid_or_expired', req.url));
  }
}

Token exchange flow

Implement a custom token exchange:
// app/api/auth/exchange-token/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getWorkOS } from '@workos-inc/authkit-nextjs';
import { saveSession } from '@workos-inc/authkit-nextjs';

export async function POST(req: NextRequest) {
  try {
    const { externalToken } = await req.json();

    // Validate the external token with your identity provider
    const externalUser = await validateExternalToken(externalToken);

    // Find or create the WorkOS user
    const user = await findOrCreateWorkOSUser(externalUser);

    // Generate WorkOS tokens for the user
    const authResponse = await getWorkOS().userManagement.authenticateWithCode({
      clientId: process.env.WORKOS_CLIENT_ID!,
      code: user.authCode,
    });

    // Save the session
    await saveSession(authResponse, req);

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('Token exchange failed:', error);
    return NextResponse.json(
      { error: 'Token exchange failed' },
      { status: 400 }
    );
  }
}

async function validateExternalToken(token: string) {
  // Implement your external token validation logic
  // ...
}

async function findOrCreateWorkOSUser(externalUser: any) {
  // Implement your user lookup/creation logic
  // ...
}

Using URL string instead of NextRequest

You can pass a URL string instead of a NextRequest object:
import { saveSession } from '@workos-inc/authkit-nextjs';
import { getWorkOS } from '@workos-inc/authkit-nextjs';

// In a server action or route handler without access to NextRequest
export async function customAuthFlow(verificationCode: string) {
  const authResponse = await getWorkOS().userManagement.authenticateWithEmailVerification({
    clientId: process.env.WORKOS_CLIENT_ID!,
    code: verificationCode,
  });

  // Pass URL string for cookie settings
  await saveSession(authResponse, process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI!);
}

Session structure

The session object must include:
interface Session {
  accessToken: string;        // JWT access token from WorkOS
  refreshToken: string;       // Refresh token for session renewal
  user: User;                 // User object from WorkOS API
  impersonator?: Impersonator; // Optional impersonator info
}

interface User {
  id: string;
  email: string;
  firstName?: string;
  lastName?: string;
  // ... other user fields from WorkOS
}

interface Impersonator {
  email: string;
  reason: string | null;
}
The session is stored in an encrypted cookie with settings from your environment variables:
  • Cookie name: WORKOS_COOKIE_NAME (default: wos-session)
  • Encryption key: WORKOS_COOKIE_PASSWORD (required, min 32 characters)
  • Max age: WORKOS_COOKIE_MAX_AGE (default: 400 days)
  • Domain: WORKOS_COOKIE_DOMAIN (default: current domain)
  • SameSite: WORKOS_COOKIE_SAMESITE (default: lax)
  • Secure: Automatically determined from request URL

Complete example: Passwordless flow

Here’s a complete passwordless authentication implementation:
// app/api/auth/send-code/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getWorkOS } from '@workos-inc/authkit-nextjs';

export async function POST(req: NextRequest) {
  try {
    const { email } = await req.json();

    // Send passwordless verification code
    await getWorkOS().userManagement.sendVerificationEmail({
      email,
    });

    return NextResponse.json({ success: true });
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to send verification code' },
      { status: 500 }
    );
  }
}
// app/api/auth/verify-code/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getWorkOS } from '@workos-inc/authkit-nextjs';
import { saveSession } from '@workos-inc/authkit-nextjs';

export async function POST(req: NextRequest) {
  try {
    const { code } = await req.json();

    const authResponse = await getWorkOS().userManagement.authenticateWithEmailVerification({
      clientId: process.env.WORKOS_CLIENT_ID!,
      code,
    });

    await saveSession(authResponse, req);

    return NextResponse.json({ success: true });
  } catch (error) {
    return NextResponse.json(
      { error: 'Invalid verification code' },
      { status: 400 }
    );
  }
}
// app/login/page.tsx
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function PasswordlessLogin() {
  const [step, setStep] = useState<'email' | 'code'>('email');
  const [email, setEmail] = useState('');
  const [code, setCode] = useState('');
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  const handleSendCode = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError(null);

    try {
      const response = await fetch('/api/auth/send-code', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email }),
      });

      if (!response.ok) throw new Error('Failed to send code');

      setStep('code');
    } catch (err) {
      setError('Failed to send verification code');
    } finally {
      setLoading(false);
    }
  };

  const handleVerifyCode = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError(null);

    try {
      const response = await fetch('/api/auth/verify-code', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ code }),
      });

      if (!response.ok) throw new Error('Invalid code');

      router.push('/dashboard');
    } catch (err) {
      setError('Invalid or expired verification code');
    } finally {
      setLoading(false);
    }
  };

  if (step === 'email') {
    return (
      <form onSubmit={handleSendCode}>
        <h1>Sign in</h1>
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder="Enter your email"
          required
        />
        <button type="submit" disabled={loading}>
          {loading ? 'Sending...' : 'Send verification code'}
        </button>
        {error && <div>{error}</div>}
      </form>
    );
  }

  return (
    <form onSubmit={handleVerifyCode}>
      <h1>Enter verification code</h1>
      <p>We sent a code to {email}</p>
      <input
        type="text"
        value={code}
        onChange={(e) => setCode(e.target.value)}
        placeholder="Enter code"
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Verifying...' : 'Verify'}
      </button>
      <button type="button" onClick={() => setStep('email')}>
        Use different email
      </button>
      {error && <div>{error}</div>}
    </form>
  );
}

Error handling

Always wrap authentication calls in try-catch blocks:
try {
  const authResponse = await getWorkOS().userManagement.authenticateWithEmailVerification({
    clientId: process.env.WORKOS_CLIENT_ID!,
    code,
  });
  
  await saveSession(authResponse, req);
} catch (error) {
  // Handle specific error types
  if (error instanceof Error) {
    if (error.message.includes('expired')) {
      return NextResponse.json({ error: 'Code expired' }, { status: 400 });
    }
    if (error.message.includes('invalid')) {
      return NextResponse.json({ error: 'Invalid code' }, { status: 400 });
    }
  }
  
  // Generic error
  return NextResponse.json({ error: 'Authentication failed' }, { status: 500 });
}

Security considerations

When implementing custom flows:
  • Always validate tokens and codes on the server side
  • Use HTTPS in production
  • Set appropriate rate limits on authentication endpoints
  • Implement CSRF protection for POST requests
  • Never expose sensitive tokens in client-side code

Build docs developers (and LLMs) love