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>
);
}
Magic link flow
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;
}
Cookie configuration
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