TL;DR: Build a custom, dependency-light magic link authentication system using Next.js 14+ App Router and PostgreSQL to avoid the bundle bloat and vendor lock-in of third-party providers like Auth0. You will learn how to generate secure tokens using Node's native
cryptomodule, store SHA-256 hashed versions in your database, and manage the login flow using Next.js Server Actions and Route Handlers.
⚡ Key Takeaways
- Create a dedicated
verification_tokensPostgreSQL table with anidx_verification_tokens_hashindex for fast, secure lookups during the verification phase. - Generate cryptographically secure pseudorandom tokens using Node's
crypto.randomBytes(32)rather than relying on predictable methods likeMath.random()or standard UUIDs. - Prevent session hijacking by never storing plaintext tokens; always use
crypto.createHash('sha256')to store a one-way hashed version in your database. - Utilize Next.js Server Actions (
'use server') to seamlessly handle login form submissions, generate token pairs, and trigger email delivery without boilerplate API routes. - Complete the authentication loop by capturing the clicked link in a Next.js Route Handler, comparing the hashed token, deleting it to prevent reuse, and issuing an HTTP-only session cookie.
When building a new Next.js application, the default move for many developers is to reach for heavy third-party identity providers like Auth0, Clerk, or Firebase.
While these platforms offer incredible convenience, they introduce significant friction as your application scales. You become locked into their ecosystem, forced into their UI constraints, and left at the mercy of their pricing tiers. Worse, integrating heavy authentication SDKs into the Next.js App Router often leads to frustrating edge-case bugs, hydration mismatches, and bloated bundle sizes.
If you are building a B2B SaaS or an internal tool, you likely don't need a massive identity platform—you just need a secure way to verify a user's identity. By leveraging Magic Link authentication, where users receive a secure, short-lived login link via email, you completely eliminate the security risks of password storage and bypass the bloat of third-party SDKs.
In this guide, we will build a dependency-light, production-ready magic link flow using Next.js 14+, raw PostgreSQL, and standard Node.js cryptography. At SoftwareCrafting, this is the exact architecture we deploy through our full-stack web development services when clients require bespoke, high-performance web portals where they retain 100% data ownership.
The Architecture and Database Schema
A secure magic link flow requires strict state management. You cannot simply email a random string and hope for the best. The architecture involves four distinct steps:
- Initiation: The user submits their email via a Next.js Server Action.
- Generation: The server generates a cryptographically secure token, hashes it, and stores the hash in PostgreSQL with a strict expiration time.
- Delivery: The plaintext token is emailed to the user as a clickable link.
- Verification: When the user clicks the link, a Next.js Route Handler captures the token, hashes it, compares it against the database, deletes it to prevent reuse, and issues an HTTP-only session cookie.
Let's start with the foundation: the database schema. We need a dedicated table for verification tokens to gracefully handle multiple login attempts.
-- PostgreSQL Schema
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE verification_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
identifier VARCHAR(255) NOT NULL, -- The user's email
token_hash VARCHAR(64) UNIQUE NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Index for fast token lookups during the verification phase
CREATE INDEX idx_verification_tokens_hash ON verification_tokens(token_hash);
Production Note: Never store verification tokens in plaintext. If your database is compromised via an SQL injection vulnerability or a leaked backup, an attacker could extract active plaintext tokens and hijack ongoing login sessions. Always store a one-way hashed version of the token.
Generating Cryptographically Secure Tokens
To generate the token, do not rely on Math.random() or standard UUIDs, as they are predictable. Instead, you must use a Cryptographically Secure Pseudorandom Number Generator (CSPRNG).
Node's native crypto module provides everything we need to generate the token and hash it for secure database storage.
// lib/auth/tokens.ts
import crypto from 'crypto';
export interface TokenPair {
plainTextToken: string;
hashedToken: string;
}
export function generateVerificationToken(): TokenPair {
// Generate 32 random bytes and convert to hex (64 characters)
const plainTextToken = crypto.randomBytes(32).toString('hex');
// Create a SHA-256 hash of the token for database storage
const hashedToken = crypto
.createHash('sha256')
.update(plainTextToken)
.digest('hex');
return {
plainTextToken,
hashedToken
};
}
This utility ensures that what we email to the user (plainTextToken) is entirely different from what we store in our database (hashedToken). When the user returns with the plaintext token, we simply hash it again and look for a match in PostgreSQL.
The Server Action: Initiating the Magic Link Flow
Next.js Server Actions are perfect for handling login form submissions. They allow us to seamlessly transition from client-side UI to server-side logic without writing boilerplate API routes.
In this step, we check if the user exists (or create them), generate our token pair, insert the hash into PostgreSQL, and trigger the email.
// app/actions/auth.ts
'use server';
import { sql } from '@/lib/db'; // Your preferred Postgres client
import { generateVerificationToken } from '@/lib/auth/tokens';
import { sendMagicLinkEmail } from '@/lib/email';
export async function signInWithEmail(formData: FormData) {
const email = formData.get('email')?.toString().toLowerCase().trim();
// Basic email validation
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { error: 'Invalid email address' };
}
try {
// 1. Ensure user exists (upsert)
await sql`
INSERT INTO users (email) VALUES (${email})
ON CONFLICT (email) DO NOTHING
`;
// 2. Generate secure token pair
const { plainTextToken, hashedToken } = generateVerificationToken();
// 3. Set expiration to 15 minutes from now
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
// 4. Invalidate any existing tokens for this email to prevent spam
await sql`
DELETE FROM verification_tokens WHERE identifier = ${email}
`;
// 5. Store the hashed token
await sql`
INSERT INTO verification_tokens (identifier, token_hash, expires_at)
VALUES (${email}, ${hashedToken}, ${expiresAt})
`;
// 6. Send the email (using Resend, AWS SES, or Nodemailer)
const magicLink = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/verify?token=${plainTextToken}`;
await sendMagicLinkEmail(email, magicLink);
return { success: 'Check your email for the login link!' };
} catch (error) {
console.error('Login error:', error);
return { error: 'Failed to initiate login. Please try again.' };
}
}
Notice how we actively delete previous tokens for the given email before inserting a new one. This ensures that if a user clicks "Resend Email" five times, only the absolute latest link will function, minimizing our attack surface.
Validating the Token and Creating the Session
When the user clicks the link in their email, they are directed to an API Route Handler. This handler performs the critical validation logic.
If the token is valid and hasn't expired, we will generate a JSON Web Token (JWT), sign it, and place it in a secure HTTP-only cookie to maintain the session. We use the jose library here instead of jsonwebtoken because jose is compatible with the V8 Edge runtime used by Next.js Middleware.
// app/api/auth/verify/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
import { SignJWT } from 'jose';
import { sql } from '@/lib/db';
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET_KEY!);
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const token = searchParams.get('token');
if (!token) {
return NextResponse.redirect(new URL('/login?error=MissingToken', req.url));
}
// 1. Hash the incoming plaintext token
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
// 2. Look up the token in PostgreSQL
const result = await sql`
SELECT identifier, expires_at
FROM verification_tokens
WHERE token_hash = ${hashedToken}
`;
const record = result.rows[0];
if (!record) {
return NextResponse.redirect(new URL('/login?error=InvalidToken', req.url));
}
// 3. Immediately delete the token so it cannot be used again
await sql`DELETE FROM verification_tokens WHERE token_hash = ${hashedToken}`;
// 4. Check expiration
if (new Date() > new Date(record.expires_at)) {
return NextResponse.redirect(new URL('/login?error=TokenExpired', req.url));
}
// 5. Create the JWT Session
const jwt = await new SignJWT({ email: record.identifier })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d') // 7 day session
.sign(JWT_SECRET);
// 6. Set the HTTP-Only cookie and redirect to the dashboard
const response = NextResponse.redirect(new URL('/dashboard', req.url));
response.cookies.set({
name: 'session_token',
value: jwt,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', // Must be 'lax', not 'strict', for email click-throughs
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
return response;
}
Production Note: Why use
sameSite: 'lax'instead of'strict'? When a user clicks a link from an external desktop email client, the browser navigates across origins. If your cookie is set tostrict, the browser will strip the newly created session cookie on the redirect, causing a frustrating loop where the user is immediately logged out.
Securing Routes with Middleware
With the session cookie securely planted in the user's browser, we need to protect our application routes. Next.js Middleware runs before a request is completed, allowing us to inspect the incoming session cookie and block unauthenticated users from accessing protected areas.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET_KEY!);
export async function middleware(req: NextRequest) {
// Define which paths require authentication
const protectedRoutes = ['/dashboard', '/settings', '/api/protected'];
const isProtected = protectedRoutes.some(route =>
req.nextUrl.pathname.startsWith(route)
);
if (!isProtected) {
return NextResponse.next();
}
const token = req.cookies.get('session_token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', req.url));
}
try {
// Verify the JWT token using 'jose' (Edge compatible)
const { payload } = await jwtVerify(token, JWT_SECRET);
// Optionally, pass the user's email to the downstream request via headers
const requestHeaders = new Headers(req.headers);
requestHeaders.set('x-user-email', payload.email as string);
return NextResponse.next({
request: {
headers: requestHeaders,
},
});
} catch (error) {
// Token is invalid or expired
const response = NextResponse.redirect(new URL('/login', req.url));
response.cookies.delete('session_token');
return response;
}
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
By verifying the JWT at the middleware level, your Next.js application avoids querying the database on every single page load. This drastically improves rendering performance and lowers database costs.
Production Security Considerations
While the above implementation is fully functional, production environments require additional defensive layers against malicious actors. We've encountered these edge cases repeatedly while building complex applications showcased in our work portfolio.
1. Implement Strict Rate Limiting
Because the Server Action triggers an email via a third-party service (like Resend or Postmark), an attacker could spam your /login form, exhausting your email quota and driving up costs.
Always wrap your Server Action in a rate limiter based on the user's IP address. Using a lightweight Redis store like Upstash is highly recommended.
// Example Upstash rate limit snippet to insert in your Server Action
import { headers } from "next/headers";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(3, "15 m"), // 3 requests per 15 minutes
});
// Inside your signInWithEmail action:
const ip = headers().get("x-forwarded-for") ?? "127.0.0.1";
const { success } = await ratelimit.limit(`login_${ip}`);
if (!success) {
return { error: "Too many login attempts. Please try again later." };
}
2. Guard Against Session Hijacking
Even though HTTP-only cookies protect your JWT from Cross-Site Scripting (XSS), you are still vulnerable to Session Hijacking if an attacker manages to steal the physical cookie from the user's device.
To mitigate this, include context inside your JWT payload. When generating the token, add an IP hash or a User-Agent fingerprint. During middleware verification, ensure the current requester's context matches the context baked into the token. If it doesn't, aggressively invalidate the session.
Final Thoughts
Building your own Magic Link authentication flow in Next.js requires a solid understanding of cryptography, database state management, and edge-compatible token verification. However, the trade-off is well worth it. You gain total control over your user data, eliminate recurring auth-provider costs, and keep your application architecture incredibly lightweight.
If your team is struggling with Next.js architecture, transitioning away from costly legacy systems, or dealing with complex caching and authentication bugs, it might be time to bring in experts. You can book a free architecture review with our backend engineers to map out a resilient, tailored solution for your exact needs.
Need help building this in production?
SoftwareCrafting is a full-stack dev agency — we ship fast, scalable React, Next.js, Node.js, React Native & Flutter apps for global clients.
Get a Free ConsultationFrequently Asked Questions
Why should I build custom magic link authentication instead of using Auth0 or Clerk in Next.js?
Relying on heavy third-party identity providers can lead to ecosystem lock-in, UI constraints, and unpredictable pricing as your application scales. Furthermore, integrating heavy SDKs into the Next.js App Router often causes edge-case bugs, hydration mismatches, and bloated bundle sizes. Building a custom, dependency-light flow gives you complete control and 100% data ownership.
How do I securely store magic link verification tokens in PostgreSQL?
You should never store verification tokens in plaintext, as a database compromise would allow attackers to extract active tokens and hijack login sessions. Instead, use Node's native crypto module to generate a SHA-256 hash of the token. Store this one-way hashed version in your PostgreSQL database alongside a strict expiration time.
Can I use Math.random() or standard UUIDs to generate magic link tokens?
No, Math.random() and standard UUIDs are predictable and not secure enough for authentication tokens. You must use a Cryptographically Secure Pseudorandom Number Generator (CSPRNG) to prevent attackers from guessing valid links. In Node.js, you can easily achieve this by using crypto.randomBytes() to generate the plaintext token.
Is a custom magic link architecture suitable for enterprise B2B SaaS applications?
Absolutely. Custom magic link flows eliminate the security risks of password storage while keeping your application lightweight and independent of third-party pricing tiers. At SoftwareCrafting, we frequently implement this exact architecture through our full-stack web development services for clients needing high-performance, bespoke web portals with strict data ownership requirements.
How do Next.js Server Actions improve the magic link authentication flow?
Server Actions allow you to handle login form submissions and seamlessly transition from client-side UI to server-side logic without writing boilerplate API routes. You can use a Server Action to securely check if a user exists, generate the token pair, insert the hash into PostgreSQL, and trigger the email delivery all in one streamlined function.
What if I need help implementing custom Next.js authentication or handling complex edge cases?
Building secure authentication requires strict state management and careful database design to prevent vulnerabilities like token reuse or session hijacking. If you need expert assistance, SoftwareCrafting offers specialized full-stack web development services to help you build production-ready, secure Next.js applications. Our team can architect and deploy bespoke authentication flows tailored perfectly to your business needs.
📎 Full Code on GitHub Gist: The complete
schema.sqlfrom this post is available as a standalone GitHub Gist — copy, fork, or embed it directly.
