Skip to content

Repository files navigation

pseudonymous-magic-link-auth

A production-ready, privacy-first magic-link authentication template for Next.js 15+.

Users authenticate with their email address but the email is never stored. Instead, a one-way HMAC-SHA256 hash of the address becomes the permanent user identifier. Once the sign-in link is dispatched, the plaintext email is gone from memory forever.

What does "pseudonymous" mean here?

This is pseudonymization as defined by GDPR Article 4(5): personal data is processed in such a way that it can no longer be attributed to a specific individual without additional information held separately.

  • Anonymous auth would mean no link exists between sessions. Users could never return to their own data.
  • Pseudonymous auth (this template) means a consistent, opaque identifier replaces the email. The same person always gets the same hash, so their data persists across logins, but the database contains no PII, only hashes.
  • The bridge between hash and identity lives solely in the PEPPER_SECRET environment variable, kept out of the database. Without it, the hashes are computationally irreversible.

This pattern satisfies the "privacy by design" principle for many use cases: you can build personalized, stateful experiences without ever knowing who your users are.

How it works

[User enters email]
       │
       ▼
HMAC-SHA256(email + PEPPER_SECRET)  ← one-way, irreversible
       │
       ▼
[Plaintext email discarded from memory]
       │
       ▼
JWT(userHash, jti, exp:15m)  →  sent via Resend
       │
       ▼
[User clicks link]  →  /api/auth/verify
       │
       ├─ jti inserted into consumed_tokens (replay protection)
       ├─ user row upserted by hash (creates account on first login)
       └─ 30-day session cookie set  →  redirect to /dashboard

Security properties

Property Implementation
Email never stored HMAC hash only; plaintext discarded immediately
Token single-use consumed_tokens table with ON CONFLICT DO NOTHING atomically rejects replays
Token expiry JWT exp claim: 15 minutes
Session expiry Signed JWT cookie: 30 days
Rate limiting (IP) In-memory sliding window: 5 requests per IP per 15 minutes. Only effective behind a trusted reverse proxy (Cloudflare, nginx) that sets CF-Connecting-IP or X-Real-IP. See Production rate limiting
Rate limiting (email) 3 magic-link sends per email address per hour; over-limit returns silent success to prevent address enumeration
Bot protection Cloudflare Turnstile widget on the login form; verified server-side before email is sent
Security headers CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy
Route protection Edge middleware validates session cookie before any page renders
Pepper rotation PEPPER_SECRET env var; rotate to invalidate all sessions without touching the DB

Tradeoffs and limitations

This pattern trades convenience and operational flexibility for privacy. Know what you're giving up before adopting it.

You can never contact your users. The email is not stored, so there is no way to send password resets, notifications, receipts, security alerts, or any outbound communication. If you have a data breach, you cannot warn anyone. If you need to deprecate the service, you cannot email users. This is a hard architectural constraint, not a configuration option.

Email deliverability is your only auth path. If Resend is down, if the magic-link email lands in spam, or if the user's mail provider delays delivery, the user is completely locked out with no fallback. There is no "try another way" option. Consider proactively advising users to whitelist your sending domain.

Magic-link emails can be forwarded. Anyone who receives the email, whether by forwarding, a shared inbox, or a compromised email account, can log in as that user. This is a property of all magic-link systems, not specific to this template. If your threat model includes compromised email accounts, magic links are not the right auth mechanism.

No individual session revocation. The session token is a signed JWT verified cryptographically. There is no session table to query. You cannot list a user's active sessions or sign out a specific device. The only way to invalidate all sessions globally is to rotate AUTH_SECRET, which signs out every user across the entire app simultaneously.

PEPPER_SECRET rotation is destructive. If the pepper changes, every existing user hash becomes unreachable. Users can still log in (they'll get a new hash from the new pepper), but their previous data (stored under the old hash) is orphaned in the database. There is no migration path without a one-time re-hashing script that requires the old pepper. Store the pepper as carefully as you would a root password.

The in-memory rate limiter is not production-safe at scale. It resets on every cold start and does not share state across multiple server instances. A determined attacker can exhaust it by triggering cold starts or routing through multiple instances. See the Production rate limiting section for the fix.

Shared email addresses create shared accounts. Two people using the same email address (a family alias, a shared work inbox) will land in the same account. This is probably rare, but there is no way to detect or prevent it.

consumed_tokens cleanup depends on logins happening. Expired tokens are pruned from the table when a successful login occurs. This cleanup runs synchronously in the verify request, adding a small amount of DB latency to every login. If your app receives a flood of magic-link requests that are never clicked (bots, spam), those rows accumulate until a real login triggers cleanup. For high-traffic deployments, add a scheduled job to prune the table independently and consider moving the cleanup to a background task.

Under the hood

Why HMAC-SHA256 instead of plain SHA256?

A plain SHA256 hash of an email can be cracked offline with a rainbow table. An attacker who steals the database just hashes a list of known email addresses and looks for matches. HMAC-SHA256 requires the secret key (PEPPER_SECRET) to produce the correct output, so the hash is only reproducible by someone who has both the email and the key. The database alone is not enough.

Two tokens, two jobs

The system uses two separate JWTs with different lifetimes and purposes:

Token Where Lifetime Contains Purpose
Login token Email link URL 15 minutes userHash, jti Proves the user controls the email address
Session token auth_session cookie 30 days userHash Proves an active authenticated session

Separating them means a stolen email link cannot be used after the session cookie has been set, and an expired session doesn't require re-verifying the email address immediately.

How replay attacks are prevented

A magic-link email could theoretically be used more than once if the server accepted the same token twice. This is prevented with a consumed_tokens table:

  1. When the user clicks the link, the server extracts the jti (JWT ID), a UUID unique to that token.
  2. It attempts to INSERT the jti into consumed_tokens using ON CONFLICT DO NOTHING.
  3. If the insert returns zero rows, the jti already exists, meaning the token was already used. The request is rejected immediately.
  4. If the insert succeeds, this is the first use. The session is created.

This is atomic: two simultaneous requests with the same token cannot both succeed because only one INSERT can win the conflict.

Why the middleware duplicates the JWT check

Next.js middleware runs in the Edge runtime, which does not support Node.js APIs. The lib/auth/session.ts file is marked server-only and uses the Node.js crypto module, so it cannot be imported in middleware. Instead, middleware manually verifies the session cookie using jose (which is Edge-compatible). The logic is identical. Only the import path differs. This is a Next.js platform constraint, not a design choice.

The session cookie contains no database reference

The auth_session cookie is a signed JWT that embeds the userHash directly. Middleware verifies the signature cryptographically without making any database calls. This keeps protected routes fast. A DB lookup only happens when the page actually needs user data, not on every navigation.

Account recovery

There is no password to reset. The email address itself is the credential. Entering the same email always produces the same hash and opens the same account. If a user loses access to their email address, they permanently lose access to their account. There is no recovery path by design. You should communicate this clearly in your app's UI.

What happens when the same email signs up twice

The verify route upserts the user with INSERT ... ON CONFLICT DO NOTHING. If the hash already exists in the users table, the insert is silently skipped and the existing account is returned. There are no duplicate accounts.

Stack

  • Next.js 15+ (App Router, Server Actions). Server Actions let the login form call server-side logic directly without a separate API route, which keeps the auth flow in one file and makes CSRF protection straightforward via allowedOrigins.
  • Drizzle ORM + PostgreSQL. Drizzle's ON CONFLICT DO NOTHING upsert is used for atomic replay-attack prevention without a round-trip check. Schema changes are type-safe and the migration files are committed to the repo.
  • Resend. Transactional email API with a generous free tier. The only job here is delivering a single email per login attempt; Resend handles deliverability, SPF/DKIM, and bounce handling.
  • jose for JWT signing/verification. The Node.js built-in crypto module cannot run in the Edge runtime (where Next.js middleware executes). jose provides the same JWT operations but is fully Edge-compatible, so the same signing logic works in both Server Actions and middleware without two separate implementations.
  • Cloudflare Turnstile. Bot protection on the login form. Unlike reCAPTCHA, Turnstile is free, requires no Google account, runs mostly invisibly for real users, and does not fingerprint visitors for ad targeting. The server-side verification call confirms the widget result was not forged.

Setup

1. Install dependencies

npm install

2. Configure environment variables

cp .env.example .env.local

Fill in .env.local:

# Generate with: openssl rand -hex 32
AUTH_SECRET=

# Generate with: openssl rand -hex 32  (separate from AUTH_SECRET)
PEPPER_SECRET=

# Your PostgreSQL connection string (e.g. Supabase)
DATABASE_URL=

# Resend API key from resend.com
RESEND_API_KEY=

# The "from" address for magic-link emails (must be verified in Resend)
RESEND_FROM=noreply@yourdomain.com

# Your app's public URL (no trailing slash)
NEXT_PUBLIC_APP_URL=http://localhost:3000

# Display name used in outgoing emails e.g. "My App <noreply@yourdomain.com>"
APP_NAME=My App

# Cloudflare Turnstile: bot protection (optional but recommended in production)
# Get keys at dash.cloudflare.com > Turnstile
# For local dev, use the always-pass test keys:
#   NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
#   TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=

Never share PEPPER_SECRET. It is the cryptographic secret that ties email addresses to user hashes. If it leaks, an attacker with the DB can reverse-lookup hashes by brute-forcing common email addresses.

3. Run database migrations

npm run db:migrate

4. Start the dev server

npm run dev

Visit http://localhost:3000/login and enter any email address.

Project structure

.
├── app/
│   ├── actions/
│   │   └── auth.ts          # requestMagicLink, logout, deleteAccount
│   ├── api/auth/verify/
│   │   └── route.ts         # magic-link callback: token → session cookie
│   ├── dashboard/
│   │   └── page.tsx         # example protected page
│   ├── login/
│   │   ├── page.tsx
│   │   └── login-form.tsx
│   ├── layout.tsx
│   └── page.tsx             # home (redirects to /dashboard if authed)
├── components/
│   └── logout-button.tsx
├── lib/
│   ├── auth/
│   │   ├── session.ts       # JWT sign/verify, cookie helpers
│   │   └── adapters/
│   │       ├── types.ts     # DbAdapter and EmailAdapter interfaces
│   │       ├── drizzle.ts   # default DB implementation
│   │       ├── resend.ts    # default email implementation
│   │       └── index.ts     # swap providers here
│   ├── db/
│   │   ├── index.ts         # Drizzle client
│   │   ├── schema.ts        # users, consumed_tokens
│   │   └── migrations/      # generated SQL migrations
│   └── rate-limit.ts        # in-memory sliding-window limiter
├── middleware.ts             # Edge route protection
├── drizzle.config.ts
├── next.config.ts
└── .env.example

Swapping providers

The database and email integrations are isolated behind two thin interfaces in lib/auth/adapters/types.ts. To swap a provider, implement the interface and change the one-line export in lib/auth/adapters/index.ts. Nothing else in the codebase needs to change.

// lib/auth/adapters/index.ts - change these two lines only
export { myPrismaAdapter as dbAdapter } from './prisma';
export { myNodemailerAdapter as emailAdapter } from './nodemailer';

Swapping the database (e.g. Prisma)

Implement DbAdapter from lib/auth/adapters/types.ts:

// lib/auth/adapters/prisma.ts
import { prisma } from '@/lib/prisma';
import type { DbAdapter } from './types';

export const prismaAdapter: DbAdapter = {
  async consumeToken(jti, expiresAt) {
    try {
      await prisma.consumedToken.create({ data: { jti, expiresAt } });
      return true;
    } catch {
      return false; // unique constraint violation = replay
    }
  },

  async pruneExpiredTokens() {
    await prisma.consumedToken.deleteMany({ where: { expiresAt: { lt: new Date() } } });
  },

  async upsertUser(hash) {
    await prisma.user.upsert({ where: { hash }, update: {}, create: { hash } });
  },

  async deleteUser(hash) {
    await prisma.user.delete({ where: { hash } });
  },
};

Your Prisma schema needs the same two tables:

model User {
  hash      String   @id
  createdAt DateTime @default(now())
}

model ConsumedToken {
  jti       String   @id
  expiresAt DateTime
}

Swapping the email provider (e.g. Nodemailer)

Implement EmailAdapter from lib/auth/adapters/types.ts:

// lib/auth/adapters/nodemailer.ts
import nodemailer from 'nodemailer';
import type { EmailAdapter } from './types';

const transporter = nodemailer.createTransport({ /* your SMTP config */ });

export const nodemailerAdapter: EmailAdapter = {
  async sendMagicLink(to, loginUrl, appName) {
    await transporter.sendMail({
      from: `${appName} <${process.env.SMTP_FROM}>`,
      to,
      subject: `Your ${appName} sign-in link`,
      html: `<a href="${loginUrl}">Sign in →</a>`,
    });
  },
};

The full interface:

interface DbAdapter {
  consumeToken(jti: string, expiresAt: Date): Promise<boolean>;
  pruneExpiredTokens(): Promise<void>;
  upsertUser(hash: string): Promise<void>;
  deleteUser(hash: string): Promise<void>;
}

interface EmailAdapter {
  sendMagicLink(to: string, loginUrl: string, appName: string): Promise<void>;
}

Adding this to an existing Next.js project

If you already have a Next.js 15+ project and just want the auth layer, copy these files across and follow the steps below.

Files to copy

lib/auth/session.ts        → your-project/lib/auth/session.ts
lib/auth/adapters/         → your-project/lib/auth/adapters/  (swap providers here)
lib/db/schema.ts           → merge the users and consumed_tokens tables into your schema
lib/db/index.ts            → skip if you already have a Drizzle client
lib/rate-limit.ts          → your-project/lib/rate-limit.ts
app/actions/auth.ts        → your-project/app/actions/auth.ts
app/api/auth/verify/       → your-project/app/api/auth/verify/
app/login/                 → your-project/app/login/  (or adapt to your login page)
middleware.ts              → merge matcher routes into your existing middleware

Install the required packages

npm install jose resend drizzle-orm postgres server-only

If you're not using Drizzle yet, also install:

npm install -D drizzle-kit

Add environment variables

Add these to your .env.local:

AUTH_SECRET=          # openssl rand -hex 32
PEPPER_SECRET=        # openssl rand -hex 32  (different from AUTH_SECRET)
RESEND_API_KEY=
RESEND_FROM=noreply@yourdomain.com
APP_NAME=My App
NEXT_PUBLIC_APP_URL=https://yourdomain.com
NEXT_PUBLIC_TURNSTILE_SITE_KEY=   # from Cloudflare Turnstile dashboard
TURNSTILE_SECRET_KEY=             # from Cloudflare Turnstile dashboard

If you already have DATABASE_URL set for Drizzle, nothing extra is needed.

Merge the schema

Add the users and consumed_tokens tables to your existing schema.ts. If you already have a users table, the minimum requirement is a hash text PRIMARY KEY column. Rename as needed and update the imports in auth.ts and verify/route.ts accordingly.

// Add to your existing lib/db/schema.ts
export const users = pgTable('users', {
  hash: text('hash').primaryKey(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  // ...your existing columns
});

export const consumedTokens = pgTable('consumed_tokens', {
  jti: text('jti').primaryKey(),
  expiresAt: timestamp('expires_at').notNull(),
});

Then generate and run a migration:

npm run db:generate && npm run db:migrate

Protect your routes

In middleware.ts, add your protected paths to the matcher:

export const config = {
  matcher: ['/dashboard/:path*', '/account/:path*'],
};

Read the session in a Server Component or Server Action

import { getSession } from '@/lib/auth/session';

// In any Server Component or Server Action:
const userHash = await getSession();
if (!userHash) redirect('/login');

// userHash is your stable, anonymous user identifier.
// Use it to query any user-specific data in your DB.
const posts = await db.select().from(posts).where(eq(posts.userHash, userHash));

Customise the magic-link email

The email HTML is in the magicLinkEmail() function at the bottom of app/actions/auth.ts. Replace it with a React Email template or any HTML string you prefer.

Point the post-login redirect somewhere useful

In app/api/auth/verify/route.ts, change the final redirect from /dashboard to wherever your app's home screen is:

const safeFrom = from.startsWith('/') ? from : '/your-home-page';

Extending this template

Add user data

The users table only has hash and created_at. Add your own columns:

// lib/db/schema.ts
export const users = pgTable('users', {
  hash: text('hash').primaryKey(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  // add your own:
  displayName: text('display_name'),
  timezone: text('timezone').default('UTC'),
});

Then run npm run db:generate && npm run db:migrate.

Protect more routes

Edit middleware.ts to add more route patterns to the matcher array:

export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*', '/your-page/:path*'],
};

Bot protection (Cloudflare Turnstile)

Without bot protection, an attacker can automate magic-link requests to:

  • Spam arbitrary inboxes with sign-in emails (using your Resend sending quota)
  • Probe whether specific email addresses have accounts (via timing differences)
  • Exhaust the IP-based rate limiter using rotating proxies

The template ships with Cloudflare Turnstile integration. Turnstile renders an invisible or one-click challenge on the login form and issues a short-lived token. The server verifies that token against Cloudflare's API before sending any email. Bots that cannot complete the challenge never reach the email-sending step.

Why Turnstile over reCAPTCHA? Turnstile is free, requires no Google account, does not serve ads or track users across sites, and is invisible for most real users, typically resolving in under a second without a puzzle. For a privacy-first auth template, that alignment matters.

Setup:

  1. Go to Cloudflare Dashboard → Turnstile → Add site
  2. Choose "Managed" (invisible by default, shows a challenge only when suspicious)
  3. Add your domain
  4. Copy the site key and secret key to your environment variables:
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_site_key
TURNSTILE_SECRET_KEY=your_secret_key

Local development: Cloudflare provides permanent test keys that always pass without any network call:

NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA

If both variables are unset, server-side verification is skipped entirely so the app works locally without a Cloudflare account. In production, leaving TURNSTILE_SECRET_KEY unset means bot protection is disabled. The server logs a warning but does not block requests. Without Turnstile, the only bot barrier is the IP rate limiter, which is bypassable via proxy rotation.

Production rate limiting

The in-memory rate limiter works across two dimensions, per IP and per email hash, but has two limitations in production:

It resets on every cold start and does not share state across instances. Two simultaneous requests hitting different Vercel function instances each see a fresh counter.

IP-based limiting only works behind a trusted reverse proxy. The limiter reads the client IP from CF-Connecting-IP, X-Real-IP, or X-Forwarded-For, in that order. If your app is reachable directly (not behind Cloudflare or an nginx reverse proxy), an attacker can forge any of these headers and bypass IP rate limiting entirely. This is a property of all header-based IP detection. The fix is to put your app behind Cloudflare (which also gives you L3/L4 DDoS protection) or configure your reverse proxy to strip and rewrite these headers.

For multi-instance deployments, swap the limiter with Upstash Redis (serverless Redis with an HTTP API that works in Edge runtimes):

// lib/rate-limit.ts - replace checkRateLimit with a Redis INCR + EXPIRE call
// Upstash's @upstash/ratelimit package wraps this in a one-liner.

Why does the per-email limit return silent success on hit? When the email rate limit is exceeded, requestMagicLink returns { success: true } instead of an error. This prevents an attacker from using the API as an oracle to confirm that a specific email address is registered and active. From the outside, a blocked request looks identical to a successful one.

Infrastructure-level DDoS protection

This template has no application-level protection against L3/L4 (volumetric) DDoS attacks. For production deployments, place your app behind Cloudflare (free tier covers DDoS mitigation) or your hosting platform's equivalent. Cloudflare has the added benefit of making the IP-based rate limiting reliable, since CF-Connecting-IP is set by Cloudflare and cannot be spoofed by clients.

Rotating PEPPER_SECRET

Rotating the pepper invalidates all existing user hashes. Existing users will get a new, empty account on next login. To rotate safely, run a migration that re-hashes all stored hashes with the new pepper before deploying the new secret. If you need this, open an issue.

Deploying

Any platform that runs Next.js works. Recommended:

  • Vercel: zero config, deploy the repo directly
  • Cloudflare Pages: edge-native, the middleware runs at the CDN level
  • Railway / Render: straightforward Node.js hosting

Set all .env.local variables as environment variables in your platform's dashboard.

License

MIT

About

Privacy-first magic-link auth for Next.js: email login without storing emails, using HMAC-SHA256 pseudonymous user IDs.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Packages

Contributors

Languages