Implementing Passwordless Auth in Next.js 15
Add magic link and OTP authentication to your Next.js application using Scalekit's headless API.
Next.js 15’s App Router expects authentication to be server-first: tokens generated on the server, verification happening in Route Handlers or Server Actions, and sessions stored in HttpOnly cookies. If you’re building passwordless authentication (magic links + OTP), traditional client-side SDKs won’t work properly with this model.
This cookbook shows you how to implement passwordless auth that works natively with Next.js 15’s architecture using Scalekit’s headless API.
The problem
Section titled “The problem”You want passwordless authentication in Next.js 15 but face these challenges:
- Client-side SDKs break App Router patterns - They expect browser-side token handling, which violates server-first principles
- Vendor UIs don’t match your design - Pre-built login pages force you to compromise on branding
- DIY is complex - Building secure token generation, email delivery, verification, and session management from scratch is a significant lift
- Cross-device failures - Magic links often break when users switch devices or email clients strip parameters
Who needs this
Section titled “Who needs this”This cookbook is for you if:
- ✅ You’re building a Next.js 15 application using App Router
- ✅ You want passwordless authentication (magic links, OTP, or both)
- ✅ You need full control over your login UI and email design
- ✅ You don’t want to migrate your existing user database
- ✅ You require server-side security for compliance
You don’t need this if:
- ❌ You’re happy with vendor-hosted login pages
- ❌ You’re using Next.js Pages Router (not App Router)
- ❌ You prefer traditional username/password authentication
The solution
Section titled “The solution”Scalekit’s passwordless API provides three server-side methods that integrate directly with Next.js 15’s architecture:
sendPasswordlessEmail()- Generates and sends magic link/OTP to user’s emailverifyPasswordlessEmail()- Validates the token/code and returns verified identityresendPasswordlessEmail()- Issues a fresh credential if the first expires
All security logic stays server-side, works with Server Actions and Route Handlers, and integrates with Edge Middleware for route protection.
Implementation
Section titled “Implementation”1. Configure Scalekit dashboard
Section titled “1. Configure Scalekit dashboard”Enable passwordless authentication in your Scalekit dashboard:
- Navigate to Authentication → Passwordless
- Select Magic Link + Verification Code for maximum reliability
- Set Expiry Period (e.g., 600 seconds for 10-minute lifetime)
- Enable Enforce same browser origin to prevent link hijacking
- (Optional) Enable Regenerate credentials on resend to invalidate old links
2. Install dependencies and configure environment
Section titled “2. Install dependencies and configure environment”npm install @scalekit-sdk/node jsonwebtokenCreate .env.local:
SCALEKIT_ENVIRONMENT_URL=env_xxxxSCALEKIT_CLIENT_ID=skc_xxxSCALEKIT_CLIENT_SECRET=your_secretAPP_URL=http://localhost:3000JWT_SECRET=your_jwt_secret3. Create session management utilities
Section titled “3. Create session management utilities”Create lib/session-store.ts to handle server-side session creation:
import jwt from 'jsonwebtoken';import { cookies } from 'next/headers';
const COOKIE = 'session';const SECRET = process.env.JWT_SECRET!;
export function createSession(email: string) { const token = jwt.sign({ email }, SECRET, { expiresIn: '7d' }); cookies().set(COOKIE, token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 7, });}
export function readSessionEmail(): string | null { const token = cookies().get(COOKIE)?.value; if (!token) return null;
try { const decoded = jwt.verify(token, SECRET) as { email: string }; return decoded.email; } catch { return null; }}
export function clearSession() { cookies().delete(COOKIE);}4. Create send email endpoint
Section titled “4. Create send email endpoint”Create app/api/auth/send-passwordless/route.ts:
import Scalekit from '@scalekit-sdk/node';import { NextRequest, NextResponse } from 'next/server';
const scalekit = new Scalekit( process.env.SCALEKIT_ENVIRONMENT_URL!, process.env.SCALEKIT_CLIENT_ID!, process.env.SCALEKIT_CLIENT_SECRET!);
export async function POST(req: NextRequest) { const { email } = await req.json();
try { const response = await scalekit.passwordless.sendPasswordlessEmail(email, { template: 'SIGNIN', expiresIn: 600, // 10 minutes state: crypto.randomUUID(), magiclinkAuthUri: `${process.env.APP_URL}/api/auth/verify`, });
return NextResponse.json({ authRequestId: response.authRequestId, expiresAt: response.expiresAt, }); } catch (error) { return NextResponse.json( { error: 'Failed to send email' }, { status: 500 } ); }}5. Create verification endpoint
Section titled “5. Create verification endpoint”Create app/api/auth/verify/route.ts with both GET (magic link) and POST (OTP) handlers:
import Scalekit from '@scalekit-sdk/node';import { NextRequest, NextResponse } from 'next/server';import { createSession } from '@/lib/session-store';
const scalekit = new Scalekit( process.env.SCALEKIT_ENVIRONMENT_URL!, process.env.SCALEKIT_CLIENT_ID!, process.env.SCALEKIT_CLIENT_SECRET!);
// Magic link verificationexport async function GET(req: NextRequest) { const url = new URL(req.url); const linkToken = url.searchParams.get('link_token'); const authRequestId = url.searchParams.get('auth_request_id') ?? undefined;
if (!linkToken) { return NextResponse.redirect( new URL('/login?error=missing_token', req.url) ); }
try { const verified = await scalekit.passwordless.verifyPasswordlessEmail( { linkToken }, authRequestId );
createSession(verified.email); return NextResponse.redirect(new URL('/dashboard', req.url)); } catch { return NextResponse.redirect( new URL('/login?error=verification_failed', req.url) ); }}
// OTP verificationexport async function POST(req: NextRequest) { const { code, authRequestId } = await req.json();
if (!code || !authRequestId) { return NextResponse.json( { error: 'Missing required fields' }, { status: 400 } ); }
try { const verified = await scalekit.passwordless.verifyPasswordlessEmail( { code }, authRequestId );
createSession(verified.email); return NextResponse.json({ success: true }); } catch { return NextResponse.json( { error: 'Invalid or expired code' }, { status: 400 } ); }}6. Add resend endpoint
Section titled “6. Add resend endpoint”Create app/api/auth/resend-passwordless/route.ts:
import Scalekit from '@scalekit-sdk/node';import { NextRequest, NextResponse } from 'next/server';
const scalekit = new Scalekit( process.env.SCALEKIT_ENVIRONMENT_URL!, process.env.SCALEKIT_CLIENT_ID!, process.env.SCALEKIT_CLIENT_SECRET!);
export async function POST(req: NextRequest) { const { authRequestId } = await req.json();
if (!authRequestId) { return NextResponse.json( { error: 'Missing authRequestId' }, { status: 400 } ); }
try { const response = await scalekit.passwordless.resendPasswordlessEmail( authRequestId );
return NextResponse.json({ authRequestId: response.authRequestId, expiresAt: response.expiresAt, }); } catch { return NextResponse.json( { error: 'Resend failed' }, { status: 400 } ); }}7. Protect routes with middleware
Section titled “7. Protect routes with middleware”Create middleware.ts in your project root:
import { NextRequest, NextResponse } from 'next/server';
export function middleware(req: NextRequest) { const protectedPath = req.nextUrl.pathname.startsWith('/dashboard'); const hasSession = Boolean(req.cookies.get('session')?.value);
if (protectedPath && !hasSession) { const url = new URL('/login', req.url); url.searchParams.set('next', req.nextUrl.pathname); return NextResponse.redirect(url); }
return NextResponse.next();}
export const config = { matcher: ['/dashboard/:path*'],};8. Build login UI (example)
Section titled “8. Build login UI (example)”Create app/login/page.tsx:
'use client';
import { useState } from 'react';import { useRouter } from 'next/navigation';
export default function LoginPage() { const [email, setEmail] = useState(''); const [authRequestId, setAuthRequestId] = useState(''); const [showOtp, setShowOtp] = useState(false); const [otp, setOtp] = useState(''); const router = useRouter();
async function handleSendEmail(e: React.FormEvent) { e.preventDefault();
const res = await fetch('/api/auth/send-passwordless', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), });
const data = await res.json(); setAuthRequestId(data.authRequestId); setShowOtp(true); }
async function handleVerifyOtp(e: React.FormEvent) { e.preventDefault();
const res = await fetch('/api/auth/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: otp, authRequestId }), });
if (res.ok) { router.push('/dashboard'); } }
return ( <div> {!showOtp ? ( <form onSubmit={handleSendEmail}> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Enter your email" required /> <button type="submit">Send magic link</button> </form> ) : ( <form onSubmit={handleVerifyOtp}> <p>Check your email for a magic link or enter the code below:</p> <input type="text" value={otp} onChange={(e) => setOtp(e.target.value)} placeholder="Enter 6-digit code" maxLength={6} /> <button type="submit">Verify</button> </form> )} </div> );}Security features
Section titled “Security features”Scalekit enforces these protections automatically:
- Rate limiting: 2 emails per minute per address, 5 OTP attempts per 10 minutes
- Short-lived tokens: Configure expiry from 60 seconds to 1 hour
- Same-browser enforcement: When enabled, links can only be verified from the originating browser
- HttpOnly sessions: Tokens never touch client JavaScript
Error handling
Section titled “Error handling”Map Scalekit errors to user-friendly messages:
function getErrorMessage(error: string): string { if (error.includes('expired')) { return 'This link has expired. Request a new one.'; } if (error.includes('rate')) { return 'Too many attempts. Please try again later.'; } if (error.includes('invalid')) { return 'Invalid code. Please check and try again.'; } return 'Verification failed. Please try again.';}Production checklist
Section titled “Production checklist”Before deploying:
- ✅ Set
secure: truefor session cookies (enforced automatically in production) - ✅ Configure production Scalekit credentials in environment variables
- ✅ Verify dashboard settings match your security requirements
- ✅ Test magic link + OTP flow on multiple email clients
- ✅ Set up monitoring for authentication errors and rate limit hits
- ✅ Configure custom email templates with your branding
Complete example
Section titled “Complete example”Full working code is available in the Scalekit GitHub repository.
Why this approach works
Section titled “Why this approach works”This implementation:
- Works natively with App Router - All sensitive operations are server-side
- Maintains full UI control - No vendor widgets or redirects to hosted pages
- Handles cross-device gracefully - OTP fallback covers magic link failures
- Requires no user migration - Works on top of your existing user store
- Stays secure by default - HttpOnly cookies, server-only verification, automatic rate limiting