SoftwareCrafting Logo

A Practical Guide to Passwordless Magic Link Authentication in Next.js App Router and PostgreSQL

BBadal SinghWeb Development12 min read27 May 2026
Illustration for A Practical Guide to Passwordless Magic Link Authentication in Next.js App Router and PostgreSQL

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 crypto module, 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_tokens PostgreSQL table with an idx_verification_tokens_hash index 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 like Math.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:

  1. Initiation: The user submits their email via a Next.js Server Action.
  2. Generation: The server generates a cryptographically secure token, hashes it, and stores the hash in PostgreSQL with a strict expiration time.
  3. Delivery: The plaintext token is emailed to the user as a clickable link.
  4. 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.

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 to strict, 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.

Available for Work

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 Consultation

Frequently Asked Questions

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.

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.

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.

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.

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.sql from this post is available as a standalone GitHub Gist — copy, fork, or embed it directly.

About the author

Badal Singh

This article was published by SoftwareCrafting engineers for founders, product teams, and developers working on real production delivery. We focus on practical tradeoffs, maintainable architecture, and implementation details that hold up outside demos.

View author profile

Last updated: 2026-05-27