Design
Set Dashboard (Gated App U…
Build the interactive dashboard UI (calendar, modals, chat, settings) on top of a working backend, consuming its contracts without re-implementing logic.
@generalized / passwordless-email-otp-auth-for-next-js
DevOps
Six-digit codes via email, one sign-in form for login and signup, hashed at rest and rate-limited. Works offline. Uses smtpfa.st.
---
name: smtpfast-otp-nextjs
description: >-
Passwordless, OTP-only email auth for a Next.js App Router app, with smtpfa.st as the mail
rail. Installs the full loop: six-digit codes hashed at rest, one live code per email,
attempt caps counted in the database, timing-safe verification, per-IP and per-email rate
limits, DB-backed sessions behind an httpOnly cookie, and a two-step sign-in form with
paste-aware code boxes. Login and signup collapse into one flow — the account is minted on
the first verified code. Works locally with zero mail config (codes print to the server
log). Use when an app wants email sign-in without passwords; not a bolt-on second factor,
and not an OAuth replacement — it composes next to OAuth fine.
---
# smtpfast/otp-nextjs — passwordless email codes, done paranoid
This skill wires **OTP-only authentication**: the user types an email, a six-digit code
lands in their inbox via [smtpfa.st](https://smtpfa.st), they type it back, and they are in.
No passwords anywhere — no hashing libraries, no reset flows, no credential-stuffing
surface. First-time emails get an account minted at the moment their code verifies, so one
form serves login and signup.
> **Mental model:** a login code is a password you issued yourself, with a ten-minute life.
> Treat it with password discipline — hash it at rest, cap the guesses, compare in constant
> time, spend it exactly once — and the flow is boring. Treat it casually and you have built
> a password system with a six-character alphabet and no lockout.
The whole system is two server actions, three small tables' worth of primitives, one mailer,
and one form. Everything the app later trusts hangs off `getCurrentUser()`.
---
## Step 0 — Discovery gate (required first)
Inspect before writing:
- **Next.js App Router with server actions?** This skill is built on `"use server"` actions
and `useActionState`. Pages Router needs API-route adaptation — say so and get agreement
before improvising.
- **Database + ORM.** Templates are Drizzle + Postgres. Any SQL store works; adapt the two
tables to the house ORM rather than importing Drizzle into a Prisma repo.
- **Existing auth?** If NextAuth, Clerk, Lucia, or a hand-rolled session system exists,
**stop and confirm you are replacing it** — never run two auth systems side by side. If
OAuth sign-in exists and stays, this flow composes next to it: same `users` table, same
session primitives, OTP as the always-works path.
- **Where sessions will be checked.** Identify the gated routes/layouts now; the last step
wires them through `getCurrentUser()`.
## Step 1 — Environment
```
SMTPFAST_API_KEY=sf_live_... # smtpfa.st dashboard → API keys
MAIL_FROM=App <[email protected]>
APP_URL=https://yourdomain.com # absolute-URL base in prod
```
Two rules here:
- `MAIL_FROM` must use a **domain verified in the smtpfa.st dashboard** — an unverified
domain sends nothing and returns `403`. Add the domain and its DNS records before blaming
the code. Details in [references/smtpfast-api.md](references/smtpfast-api.md).
- **The flow must work with no key at all.** The mailer's dev fallback prints the code to
the server log when `SMTPFAST_API_KEY` is unset, so local sign-in works on day zero.
Never make mail config a prerequisite for running the app.
## Step 2 — Schema
Two tables, from [templates/schema.ts](templates/schema.ts):
- `login_codes` — one row per email (unique index; the upsert target), holding
`code_hash`, `expires_at`, `attempts`, `consumed_at`. The unique-per-email index is
load-bearing: it is what makes "a new request replaces the old code" a one-statement
upsert instead of a cleanup job.
- `sessions` — `id` is the **sha256 of the cookie token**, never the token itself, plus
`user_id` and `expires_at`.
Your `users` table needs at minimum a unique, case-insensitive email (citext, or lowercase
before every read and write — pick one and be consistent). Add `verified_at` if you want to
sweep abandoned accounts later; a verified code already proves inbox control, so this flow
does not require a separate verification step to log someone in.
## Step 3 — The mailer
[templates/mailer.ts](templates/mailer.ts) is a plain `fetch` against
`POST https://smtpfa.st/api/v1/emails` with a Bearer key — **no SDK dependency**. Request
shape: `{ from, to: [email], subject, html, text }`. Both bodies always: `html` for people,
`text` for their filters.
Copy rules for the code email, all deliberate:
- **The code goes in the subject line** — "482913 is your App sign-in code". Most users
never open the email; the notification is the UI.
- Large, monospaced, letter-spaced code in the body. It is being read across a phone
notification and retyped, or copied — make both trivial.
- State the expiry ("expires in 10 minutes") and the no-action line ("if you didn't ask
for it, ignore this — nothing happens without the code").
- No links in the code email. A code email with links trains users to click links in auth
emails, which is the phishing posture you are trying to retire.
## Step 4 — The two actions
[templates/auth-actions.ts](templates/auth-actions.ts) carries the whole flow. These ten
rules are the skill — when in doubt, the rule wins over convenience. The reasoning behind
each lives in [references/security-model.md](references/security-model.md).
1. Codes come from **`crypto.randomInt(0, 1_000_000)`**, zero-padded to six digits. Never
`Math.random()` — it is predictable, and a predictable OTP is no OTP.
2. The database stores **`sha256(code)` only**. A leaked `login_codes` table must be
worthless ten minutes later at most.
3. **One live code per email.** The insert is an upsert on the email's unique index:
a resend replaces the old code, resets `attempts`, clears `consumed_at`. Two valid
codes for one inbox is two chances per guess window.
4. **Ten-minute TTL.** Long enough for a slow inbox, short enough that a stolen phone
notification goes stale.
5. **Five attempts per code, counted in the database row** — not in memory. Process
restarts must not refill the guess budget. At six digits and five tries, brute force
odds are 1 in 200,000 per code.
6. Compare hashes with **`timingSafeEqual`**, never `===`.
7. **Verification consumes the code** — set `consumed_at` before minting the session. A
code works exactly once, including replays from the same user double-clicking.
8. **Rate limit both actions.** Requesting: per-IP (10 / 15 min) AND per-email (3 / 15 min)
— every request costs a real email, and the per-email throttle is what stops someone
burning your smtpfa.st quota into a stranger's inbox. Verifying: per-IP (30 / 15 min)
on failures, cleared on success. Templates:
[templates/rate-limit.ts](templates/rate-limit.ts),
[templates/client-ip.ts](templates/client-ip.ts).
9. **Answers never reveal whether an account exists.** Requesting a code returns the same
"sent" for every well-formed email — new emails simply get an account minted when their
first code verifies. Wrong-code errors are the same shape regardless of account state.
10. Sessions, from [templates/auth.ts](templates/auth.ts): a 32-byte random token in an
**httpOnly, SameSite=Lax, Secure cookie**; the database stores its sha256 as the
session id; expiry is checked server-side on every read. The account-minting seam in
the verify action is marked — put your username/profile defaults there.
## Step 5 — The form
[templates/EmailOtpForm.tsx](templates/EmailOtpForm.tsx): two steps in one component —
email in, then six code boxes. The details that make it feel right:
- `autoComplete="one-time-code"` on the first box — iOS and Android offer the code straight
from the notification.
- Paste fans out across all six boxes; backspace walks left; a full code enables submit.
- "Resend code" (which silently invalidates the old code — rule 3) and "Different email"
affordances under the boxes.
- The template ships neutral Tailwind — restyle it to the host design system; the behavior
is the part to keep.
## Step 6 — Verify end to end
Run the app with no mail key and walk the loop before calling it done:
- Request a code → it prints in the server log → verify → session cookie set →
`getCurrentUser()` returns the user in a gated route.
- Wrong code five times → the sixth try reports the code stale even with the right digits.
- Resend, then try the first code → rejected; the resent code works.
- Let a code expire (drop the TTL to seconds temporarily) → clear "expired" message.
- Hammer request past the per-email cap → throttle message with minutes remaining.
- With a real key: send to a real inbox, confirm subject-line code and both bodies render.
## Refusals — what this skill argues back on
- **No magic links as the primary factor.** Corporate mail scanners and prefetchers open
links; a code that must be typed into the existing tab cannot be consumed by a scanner or
hijacked into someone else's browser. Links are fine for a secondary "verify this
address" nicety — not for authentication.
- **No 4-digit codes, no 5.** Six digits against a 5-attempt cap is the floor. Below that
the guess odds stop being a rounding error.
- **Never store or log a plaintext code server-side** — the dev-mode console print exists
only when mail is unconfigured, which is never production.
- **Never put the code in a URL** — URLs land in histories, proxies, and referrer logs.
- **No password fallback.** If the product later wants passwords, that is a different auth
system, not an edit to this one. The absence of passwords is the security posture.
- **Don't skip the per-email throttle** because "the IP limit covers it" — it does not; a
botnet has many IPs and your victim has one inbox and you have one mail quota.
## Escape hatches
- **Multi-replica deployments:** the in-memory rate limiter is per-process by design (and
says so). The seam is three functions — `retryAfter`, `recordAttempt`, `clearAttempts` —
swap the Map for Redis or a DB table without touching the actions. The in-DB attempts cap
is the real backstop either way.
- **Session length** defaults to 30 days sliding-from-issue; tune `SESSION_DAYS`.
- **Code TTL / attempt budget** are two constants at the top of the actions file; loosen
them only with a reason you can say out loud.
- **smtpfa.st tiers:** free is 3,000 emails/month — that is 3,000 sign-ins. Watch the
dashboard when auth traffic grows; overflow is $1 per 1,000.
# Security model — why every rule in the skill exists
The threat model for email OTP is small and concrete. Each defense in the skill
maps to a specific attack; if you are tempted to drop one, name the attack it
covers and say why it no longer applies.
## The attacks
**Guessing.** A six-digit code is one of 1,000,000 values. Undefended, an
attacker who can call `verify` freely wins in minutes. Defenses, layered:
- 5 attempts per code, counted **in the database row** — survives process
restarts and multiple replicas; memory counters do not.
- Per-IP verify throttle (30 failures / 15 min) — slows distributed guessing.
- 10-minute TTL — bounds the total guessing window per code.
- One live code per email — a resend must not hand the attacker a second
simultaneous target.
Combined: ≤5 guesses against 10⁶ values per code, 1-in-200,000 per issued code.
An attacker triggering resends does not accumulate chances, because each resend
replaces the previous code.
**Database theft.** If `login_codes` leaks, plaintext codes let the thief log
in as anyone with a pending code. Stored as `sha256(code)`, the rows are
useless: with ≤10 minutes of validity and the verify endpoint rate-limited, an
offline brute force of a hash worth one login attempt is not a trade anyone
makes. (No salt needed at this TTL and value — the hash is a tripwire, not a
password vault. If that ever changes, the TTL was set wrong, not the hash.)
**Timing.** `codeHash === inputHash` leaks equality position through response
time. It is a marginal oracle over HTTP, but `crypto.timingSafeEqual` costs one
line — spend it and stop thinking about it.
**Replay.** A verified code must die. `consumed_at` is written before the
session is created; the same code re-submitted (double-click, retried request,
attacker replaying a sniffed form body) hits the `stale` branch. Single-use is
non-negotiable.
**Enumeration.** "We couldn't find that account" tells an attacker which emails
have accounts. This flow's shape removes the leak: every well-formed email gets
"sent", because unknown emails are simply future accounts (minted on first
verified code). Keep verify errors account-agnostic too — "wrong code" and
"expired" reference the code, never the account.
**Quota burning / mail-bombing.** Every request sends a real, paid email. Two
throttles, both required:
- per-IP (10 / 15 min): one attacker cannot drain the mail quota.
- per-email (3 / 15 min): many IPs (a botnet) cannot bomb one victim inbox and
cannot use your app as a harassment cannon. The IP limit does NOT cover this.
**Session theft.** The cookie token is the crown jewel once auth succeeds:
- 32 random bytes (`crypto.randomBytes`), base64url — unguessable.
- `httpOnly` (no script access), `SameSite=Lax` (CSRF posture for top-level
navigations), `Secure` in production.
- The database stores `sha256(token)` as the session id, so a leaked sessions
table cannot be replayed into cookies.
- Expiry enforced server-side on every `getCurrentUser()` — never trust the
cookie's own expiry, that is client-controlled.
**Spoofed client identity.** Rate-limit keys use the client IP. Behind a proxy
or CDN, the leftmost `X-Forwarded-For` entry is attacker-supplied; prefer the
edge-set header your infra guarantees (`CF-Connecting-IP` behind Cloudflare),
then fall back. See `templates/client-ip.ts`. Getting this wrong turns the IP
throttle into a header the attacker types.
## What this flow deliberately does not defend against
Name these honestly rather than pretending:
- **A compromised inbox.** Email OTP's root of trust is inbox control — same as
every password-reset flow ever shipped. If the mailbox is stolen, the account
is stolen. That ceiling is shared with passwords-plus-reset; OTP just removes
the password's extra attack surface (reuse, stuffing, weak choices).
- **Phishing in real time.** A proxy site can relay a live code within its TTL.
Mitigations (origin-bound codes, passkeys) are a different tier of auth;
if the product needs phishing resistance, add passkeys alongside — do not
bolt complexity onto the OTP.
- **The mail provider reading codes.** smtpfa.st transports the plaintext code
by definition. TTL + single-use bounds that exposure; it is the same trust
every email-based auth extends to its mail rail.
## Operational notes
- The in-memory limiter is **per-process**. Single replica: fine, and the DB
attempt cap backstops it anyway. Multiple replicas: swap the Map for Redis or
a small table behind the same three-function seam. Do not ship multi-replica
believing the memory limiter is global — it quietly divides every limit by
the replica count.
- Sweep consumed/expired `login_codes` rows opportunistically (a delete on
insert, or a daily job) — the table should stay near one row per active
sign-in.
- Log auth *events* (request throttled, code expired, attempts exceeded) —
never the code, and in production never the email alongside such events
without a reason.
# smtpfa.st — the API surface this skill uses
Verified against the live OpenAPI spec (`https://smtpfa.st/api/v1/openapi.json`).
smtpfa.st is a transactional email API that tracks the full delivery lifecycle
(queued → accepted → delivered → opened) instead of going quiet after the 200.
This skill uses exactly one endpoint; the rest of the platform (webhooks, live
event tail, analytics, batch) is there when you want delivery observability.
## Send an email
```
POST https://smtpfa.st/api/v1/emails
Authorization: Bearer sf_live_xxxxxxxxx
Content-Type: application/json
```
Request body (`from`, `to`, `subject` required):
```json
{
"from": "App <[email protected]>",
"to": ["[email protected]"],
"subject": "482913 is your App sign-in code",
"html": "<p>...</p>",
"text": "Your App sign-in code: 482913"
}
```
- `to` is an **array**, even for one recipient.
- Send both `html` and `text`. The plain-text part is for spam filters and
text-mode clients; auth mail deliverability matters more than most mail.
- Do NOT include `{{unsubscribe_url}}` in auth emails — that placeholder is for
broadcast mail. Transactional sign-in codes are not subscription content.
Success — `200` with the queued email object:
```json
{ "id": "email_abc123", "status": "queued", "...": "..." }
```
Errors — `{ "error": "human-readable message" }` with:
| Status | Meaning | What to do in the auth flow |
|--------|---------|------------------------------|
| 400 | malformed payload | bug in the mailer — fix, don't retry |
| 401 | invalid or missing API key | check `SMTPFAST_API_KEY`; in dev, fall back to the console print |
| 403 | **domain not verified** or key lacks permission | verify the `MAIL_FROM` domain in the dashboard (DNS records); the code cannot fix this |
| 429 | rate limited | honor `Retry-After`; surface "try again in a moment" to the user |
| 500 | provider-side | treat as send failure; the user retries via "Resend code" |
## Failure posture for auth mail
The mailer must **return a boolean, not throw**. A failed send surfaces to the
user as "couldn't send the email — try again in a moment", and the retry is the
user pressing the button again (which mints a fresh code — old one dies by
upsert). Don't build automatic retry loops around a 6-digit secret; every send
is a new code, and silent retries can deliver two codes out of order.
## Dev fallback
When `SMTPFAST_API_KEY` is unset, print instead of send:
```
[mail:dev] [email protected] · "482913 is your App sign-in code"
Your App sign-in code: 482913
```
Also do this in non-production when the API answers non-2xx (real key, e.g.
unverified domain): warn with the provider's response body, then print the
code. Local auth keeps working while DNS propagates.
## Account facts worth knowing
- Keys look like `sf_live_...` and come from the dashboard.
- Sending domain must be added and DNS-verified before `from` works — this is
the #1 first-run failure (403).
- Free tier: 3,000 emails/month, 1 domain. Each sign-in costs one email, so the
free tier is ~3,000 logins/month. Overflow is $0.001/email on paid tiers.
- There is also `POST /v1/emails/batch` (up to 100 per call) and
`GET /v1/emails/{id}` for delivery status — useful for ops dashboards, not
needed for the auth flow.
"use server";
/* The whole OTP flow: two server actions. requestLoginCode mints a hashed
six-digit code and mails it via smtpfa.st; verifyLoginCode checks it with
password discipline (timing-safe, attempt-capped, single-use) and mints
the account on a first-timer's verified code — one form serves login and
signup, and no answer ever reveals whether an account exists. */
import { createHash, randomInt, timingSafeEqual } from "node:crypto";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { eq } from "drizzle-orm";
import { db } from "@/db"; // adapt to the host repo's db export
import { loginCodes, users } from "@/db/schema";
import { createSession, destroySession } from "./auth";
import { ipFromHeaders } from "./client-ip";
import { sendLoginCode } from "./mailer";
import { clearAttempts, recordAttempt, retryAfter } from "./rate-limit";
export type AuthState = { error: string } | { sent: true } | undefined;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const RL_WINDOW = 15 * 60_000; // 15 minutes
const CODE_TTL_MS = 10 * 60_000; // codes die after 10 minutes
const MAX_CODE_ATTEMPTS = 5; // per code, counted in the DB row
async function clientIp(): Promise<string> {
return ipFromHeaders(await headers());
}
const inMinutes = (sec: number) => Math.max(1, Math.ceil(sec / 60));
const sha256 = (value: string) =>
createHash("sha256").update(value).digest("hex");
/* ---------- step 1: email in, six digits out ---------- */
export async function requestLoginCode(
_prev: AuthState,
formData: FormData,
): Promise<AuthState> {
const email = String(formData.get("email") ?? "")
.trim()
.toLowerCase();
if (!EMAIL_RE.test(email)) return { error: "That email doesn't look right." };
/* codes cost an email each — throttle by sender IP AND by target inbox.
The per-email cap is what stops a botnet using the app to mail-bomb one
victim; the IP cap alone does not cover it. */
const ipKey = `otp-request:ip:${await clientIp()}`;
const emailKey = `otp-request:email:${email}`;
const wait = retryAfter(ipKey, 10) ?? retryAfter(emailKey, 3);
if (wait) {
return {
error: `Too many codes requested. Try again in ${inMinutes(wait)} minute(s).`,
};
}
recordAttempt(ipKey, RL_WINDOW);
recordAttempt(emailKey, RL_WINDOW);
/* crypto-grade randomness — Math.random() is predictable, and a
predictable OTP is no OTP */
const code = randomInt(0, 1_000_000).toString().padStart(6, "0");
const expiresAt = new Date(Date.now() + CODE_TTL_MS);
/* one live code per email — a new request REPLACES the old code (and
resets its guess budget) via upsert on the unique email index */
await db
.insert(loginCodes)
.values({ email, codeHash: sha256(code), expiresAt })
.onConflictDoUpdate({
target: loginCodes.email,
set: {
codeHash: sha256(code),
expiresAt,
attempts: 0,
consumedAt: null,
createdAt: new Date(),
},
});
if (!(await sendLoginCode(email, code))) {
return { error: "Couldn't send the email — try again in a moment." };
}
/* same answer whether the account exists or not — requesting a code must
never double as an account-existence oracle */
return { sent: true };
}
/* ---------- step 2: verify the code; first-timers get an account ---------- */
export async function verifyLoginCode(
_prev: AuthState,
formData: FormData,
): Promise<AuthState> {
const email = String(formData.get("email") ?? "")
.trim()
.toLowerCase();
const code = String(formData.get("code") ?? "").trim();
if (!EMAIL_RE.test(email) || !/^\d{6}$/.test(code)) {
return { error: "That code doesn't look right." };
}
const ipKey = `otp-verify:ip:${await clientIp()}`;
const wait = retryAfter(ipKey, 30);
if (wait) {
return {
error: `Too many attempts. Try again in ${inMinutes(wait)} minute(s).`,
};
}
const [row] = await db
.select()
.from(loginCodes)
.where(eq(loginCodes.email, email))
.limit(1);
/* a code is dead if it never existed, was already spent, timed out, or
ran out of guesses — all reported with the same account-agnostic copy */
const stale =
!row ||
row.consumedAt !== null ||
row.expiresAt.getTime() < Date.now() ||
row.attempts >= MAX_CODE_ATTEMPTS;
const match =
!stale &&
timingSafeEqual(Buffer.from(row.codeHash), Buffer.from(sha256(code)));
if (!match) {
recordAttempt(ipKey, RL_WINDOW);
if (row && !stale) {
await db
.update(loginCodes)
.set({ attempts: row.attempts + 1 })
.where(eq(loginCodes.id, row.id));
}
return {
error: stale
? "That code expired — request a fresh one."
: "Wrong code. Check the email and try again.",
};
}
/* consume BEFORE minting the session — a code works exactly once, and a
double-submitted form must hit the stale branch on its second pass */
await db
.update(loginCodes)
.set({ consumedAt: new Date() })
.where(eq(loginCodes.id, row.id));
clearAttempts(ipKey);
/* returning face → straight in */
const [existing] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, email))
.limit(1);
if (existing) {
await createSession(existing.id);
redirect("/"); // send returning users wherever the app calls home
}
/* ────────────────────────────────────────────────────────────────────
ACCOUNT-MINTING SEAM — first verified code for this email. Put the
host app's defaults here: username generation, profile rows, plan
selection, welcome flows. Handle a unique-email race (two tabs
verifying simultaneously) by re-selecting and signing into the row
that won. The verified code already proves inbox control, so
verifiedAt can be set immediately.
──────────────────────────────────────────────────────────────────── */
const [created] = await db
.insert(users)
.values({ email, verifiedAt: new Date() })
.returning({ id: users.id });
await createSession(created.id);
redirect("/"); // or the app's onboarding route
}
export async function logout(): Promise<void> {
await destroySession();
redirect("/");
}
/* Session auth primitives. Sign-in is passwordless (email OTP —
auth-actions.ts); sessions live in the DB and the cookie carries a random
token whose sha256 is the session id. Everything the app trusts about
"who is this" flows through getCurrentUser(). */
import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { db } from "@/db"; // adapt to the host repo's db export
import { sessions, users } from "@/db/schema";
const SESSION_COOKIE = "app_session"; // rename per app
const SESSION_DAYS = 30;
const tokenId = (token: string) =>
createHash("sha256").update(token).digest("hex");
export async function createSession(userId: string) {
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + SESSION_DAYS * 86_400_000);
await db
.insert(sessions)
.values({ id: tokenId(token), userId, expiresAt });
const store = await cookies();
store.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
expires: expiresAt,
});
}
export type CurrentUser = {
id: string;
email: string;
/* extend with the host app's user fields */
};
export async function getCurrentUser(): Promise<CurrentUser | null> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (!token) return null;
const [row] = await db
.select({
id: users.id,
email: users.email,
expiresAt: sessions.expiresAt,
})
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.id, tokenId(token)))
.limit(1);
/* expiry is enforced HERE, server-side — the cookie's own expiry is
client-controlled and merely cosmetic */
if (!row || row.expiresAt.getTime() < Date.now()) return null;
return { id: row.id, email: row.email };
}
export async function destroySession() {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (token) {
await db.delete(sessions).where(eq(sessions.id, tokenId(token)));
}
store.delete(SESSION_COOKIE);
}
/* Best available client IP from the proxy chain — this keys the rate
limits, so getting it wrong turns the IP throttle into a header the
attacker types. Behind Cloudflare the true client is CF-Connecting-IP
(Cloudflare sets it and strips any client-sent copy), whereas
X-Forwarded-For's leftmost entry is attacker-supplied unless YOUR edge
overwrites it. Prefer the header your infra guarantees; fall back for
local dev. Adjust the preferred header to the actual edge in front of
the app (CF-Connecting-IP, X-Real-IP, Fly-Client-IP, ...). */
export function ipFromHeaders(headers: Headers): string {
return (
headers.get("cf-connecting-ip")?.trim() ||
headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
"local"
);
}
"use client";
/* Passwordless sign-in, two steps in one component: email → six-digit code.
First-timers get their account minted on a verified code — the same form
serves login and signup, so never present separate ones. Styling here is
neutral Tailwind; restyle to the host design system, keep the behavior:
one-time-code autofill, paste fan-out, resend, different-email. */
import { useActionState, useRef, useState } from "react";
import { requestLoginCode, verifyLoginCode } from "@/lib/auth-actions";
const CODE_LENGTH = 6;
function ErrorNote({ message }: { message: string }) {
return (
<p
role="alert"
className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800"
>
{message}
</p>
);
}
/* Six boxes, one digit each — auto-advance, backspace walks left, and a
pasted code fans out across all of them. autoComplete="one-time-code" on
the first box lets iOS/Android offer the code from the notification. */
function CodeBoxes({
value,
onChange,
}: {
value: string;
onChange: (code: string) => void;
}) {
const refs = useRef<(HTMLInputElement | null)[]>([]);
const digits = Array.from({ length: CODE_LENGTH }, (_, i) => value[i] ?? "");
const write = (next: string[]) => onChange(next.join(""));
return (
<div className="flex justify-between gap-2">
{digits.map((digit, i) => (
<input
key={i}
ref={(el) => {
refs.current[i] = el;
}}
type="text"
inputMode="numeric"
autoComplete={i === 0 ? "one-time-code" : "off"}
aria-label={`Digit ${i + 1}`}
value={digit}
onChange={(e) => {
const ch = e.target.value.replace(/\D/g, "").slice(-1);
const next = [...digits];
next[i] = ch;
write(next);
if (ch && i < CODE_LENGTH - 1) refs.current[i + 1]?.focus();
}}
onKeyDown={(e) => {
if (e.key === "Backspace" && !digits[i] && i > 0) {
refs.current[i - 1]?.focus();
}
}}
onPaste={(e) => {
const pasted = e.clipboardData
.getData("text")
.replace(/\D/g, "")
.slice(0, CODE_LENGTH);
if (!pasted) return;
e.preventDefault();
onChange(pasted);
refs.current[Math.min(pasted.length, CODE_LENGTH - 1)]?.focus();
}}
className="size-12 rounded-lg border border-gray-300 bg-white text-center font-mono text-xl focus:border-gray-900 focus:outline-none"
/>
))}
</div>
);
}
export default function EmailOtpForm() {
const [requestState, requestAction, requesting] = useActionState(
requestLoginCode,
undefined,
);
const [verifyState, verifyAction, verifying] = useActionState(
verifyLoginCode,
undefined,
);
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [editingEmail, setEditingEmail] = useState(true);
const sent = Boolean(requestState && "sent" in requestState) && !editingEmail;
const requestError =
requestState && "error" in requestState ? requestState.error : undefined;
const verifyError =
verifyState && "error" in verifyState ? verifyState.error : undefined;
if (!sent) {
return (
<form
action={(fd) => {
setEditingEmail(false);
setCode("");
requestAction(fd);
}}
className="grid gap-5"
>
<label className="grid gap-1.5">
<span className="text-sm font-medium">Email</span>
<input
name="email"
type="email"
autoComplete="email"
placeholder="[email protected]"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="rounded-lg border border-gray-300 px-4 py-3 text-base focus:border-gray-900 focus:outline-none"
/>
</label>
{requestError && <ErrorNote message={requestError} />}
<button
type="submit"
disabled={requesting}
className="rounded-lg bg-gray-900 px-6 py-3 text-base font-medium text-white disabled:cursor-wait disabled:opacity-60"
>
{requesting ? "Sending…" : "Email me a code"}
</button>
<p className="text-center text-xs text-gray-500">
No password — a six-digit code lands in your inbox.
</p>
</form>
);
}
return (
<div className="grid gap-5">
<p className="text-sm text-gray-600">
We sent a six-digit code to{" "}
<span className="font-medium text-gray-900">{email}</span>. It expires
in 10 minutes.
</p>
<form action={verifyAction} className="grid gap-5">
<input type="hidden" name="email" value={email} />
<input type="hidden" name="code" value={code} />
<CodeBoxes value={code} onChange={setCode} />
{verifyError && <ErrorNote message={verifyError} />}
<button
type="submit"
disabled={verifying || code.length !== CODE_LENGTH}
className="rounded-lg bg-gray-900 px-6 py-3 text-base font-medium text-white disabled:cursor-not-allowed disabled:opacity-60"
>
{verifying ? "Checking…" : "Sign in"}
</button>
</form>
<div className="flex items-center justify-between gap-4">
<button
type="button"
onClick={() => {
setEditingEmail(true);
setCode("");
}}
className="text-sm font-medium text-gray-600 hover:text-gray-900"
>
← Different email
</button>
{/* resend silently invalidates the previous code — one live code
per email, always */}
<form
action={(fd) => {
setCode("");
requestAction(fd);
}}
>
<input type="hidden" name="email" value={email} />
<button
type="submit"
disabled={requesting}
className="text-sm font-medium text-gray-900 underline disabled:opacity-60"
>
{requesting ? "Sending…" : "Resend code"}
</button>
</form>
</div>
{requestError && <ErrorNote message={requestError} />}
</div>
);
}
/* Transactional mail through smtpfa.st's REST API — a plain fetch, no SDK.
Without SMTPFAST_API_KEY (local dev) messages print to the server log, so
the whole auth flow works before mail is wired up. Returns booleans, never
throws: a failed send is a user-visible "try again", not a 500. */
const SMTPFAST_URL = "https://smtpfa.st/api/v1/emails";
/* set these for the host app */
const APP_NAME = "App";
const fromAddress = () =>
process.env.MAIL_FROM ?? `${APP_NAME} <[email protected]>`;
export const mailConfigured = () => Boolean(process.env.SMTPFAST_API_KEY);
/* email-safe shell: inline styles only — restyle to the host brand */
function shell(body: string): string {
return `<!doctype html>
<html>
<body style="margin:0;padding:0;background:#f6f6f4;">
<div style="max-width:520px;margin:0 auto;padding:40px 24px;font-family:Georgia,'Times New Roman',serif;color:#1a1a1a;">
<p style="font-size:24px;margin:0 0 28px;">${APP_NAME}</p>
${body}
</div>
</body>
</html>`;
}
async function send(
to: string,
subject: string,
html: string,
text: string,
): Promise<boolean> {
if (!mailConfigured()) {
console.log(`[mail:dev] to=${to} · "${subject}"\n${text}`);
return true;
}
try {
const res = await fetch(SMTPFAST_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SMTPFAST_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: fromAddress(),
to: [to],
subject,
html,
text,
}),
});
if (!res.ok && process.env.NODE_ENV !== "production") {
/* dev with a real key but e.g. an unverified domain (403) — surface
the provider's complaint, then fall back to the log so local auth
flows keep working while DNS propagates */
console.warn(`[mail:dev] smtpfa.st ${res.status}: ${await res.text()}`);
console.log(`[mail:dev] to=${to} · "${subject}"\n${text}`);
return true;
}
return res.ok;
} catch {
return false;
}
}
/* The code email. Deliberate choices: the code IS the subject (most users
act from the notification without opening the mail); big monospaced code
in the body; expiry and the no-action line stated; NO links — auth emails
with links train the exact clicking habit phishing depends on. */
export function sendLoginCode(email: string, code: string) {
return send(
email,
`${code} is your ${APP_NAME} sign-in code`,
shell(`
<p style="font-size:16px;line-height:1.6;margin:0 0 20px;">Here's your sign-in code:</p>
<p style="font-size:40px;letter-spacing:0.3em;font-family:Menlo,Consolas,monospace;margin:0 0 20px;">${code}</p>
<p style="font-size:14px;line-height:1.6;color:#555;margin:0;">It expires in 10 minutes. If you didn't ask for it, ignore this email — nothing happens without the code.</p>
`),
`Your ${APP_NAME} sign-in code: ${code}\nIt expires in 10 minutes. If you didn't ask for it, ignore this email.`,
);
}
/* Fixed-window rate limiting, in memory. Correct for a single replica; the
in-database attempts cap is the real backstop either way. Scaling past one
process/pod: swap the Map for Redis or a small table — the three-function
seam below is the whole contract, the callers never change. */
type Bucket = { count: number; resetAt: number };
const buckets = new Map<string, Bucket>();
/* keep the map from growing without bound under a spray of distinct keys */
function sweep(now: number) {
if (buckets.size < 5000) return;
for (const [key, bucket] of buckets) {
if (bucket.resetAt <= now) buckets.delete(key);
}
}
/** If `key` is already at/over `limit` in the current window, returns the
seconds until it resets; otherwise null. Does not count the check. */
export function retryAfter(key: string, limit: number): number | null {
const now = Date.now();
const bucket = buckets.get(key);
if (!bucket || bucket.resetAt <= now) return null;
if (bucket.count >= limit) {
return Math.ceil((bucket.resetAt - now) / 1000);
}
return null;
}
/** Count one attempt against `key`, opening a fresh window if needed. */
export function recordAttempt(key: string, windowMs: number): void {
const now = Date.now();
sweep(now);
const bucket = buckets.get(key);
if (!bucket || bucket.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
} else {
bucket.count += 1;
}
}
/** Wipe a key's counter — call on a legitimate success. */
export function clearAttempts(key: string): void {
buckets.delete(key);
}
/* Drizzle + Postgres schema for the OTP flow — adapt table/column style to
the host repo. Two tables; your existing `users` table stays the owner of
identity (it only needs a unique, case-insensitive email). */
import { sql } from "drizzle-orm";
import {
bigint,
customType,
integer,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
/* case-insensitive email (citext extension: CREATE EXTENSION IF NOT EXISTS
citext). If you'd rather not, use text() and lowercase the email in every
read and write — pick one strategy and never mix them. */
const citext = customType<{ data: string }>({
dataType() {
return "citext";
},
});
/* Email sign-in codes — only the sha256 of the code is stored; the unique
email index is load-bearing (it makes "one live code per email" a single
upsert: a resend replaces the old code instead of coexisting with it). */
export const loginCodes = pgTable(
"login_codes",
{
id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
email: citext("email").notNull(),
codeHash: text("code_hash").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
/* counted here, not in memory — the guess budget must survive restarts */
attempts: integer("attempts").notNull().default(0),
consumedAt: timestamp("consumed_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [uniqueIndex("login_codes_email_idx").on(t.email)],
);
/* DB-backed sessions — id is the sha256 of the cookie token, never the
token itself; a leaked sessions table must not replay into cookies. */
export const sessions = pgTable("sessions", {
id: text("id").primaryKey(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
});
/* Minimal users table if the repo doesn't have one yet — otherwise keep
yours and just ensure the unique case-insensitive email index exists. */
export const users = pgTable(
"users",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
email: citext("email").notNull(),
/* optional: lets a cron sweep accounts that never proved their inbox
beyond the first code (rarely needed — a verified code IS proof) */
verifiedAt: timestamp("verified_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [uniqueIndex("users_email_idx").on(t.email)],
);
Excerpt — the full skill ships with the install.
Start by confirming the app uses Next.js App Router with server actions, has a SQL database with Drizzle or another ORM, and doesn't already have a competing auth system running. If it does, you're replacing it — get agreement first. Set up environment variables for smtpfa.st (SMTPFASTAPIKEY, MAILFROM, APPURL); local development works without the key, printing codes to the server log instead. Add two tables to your schema: logincodes (one row per email, holding the sha256 of the code, expiry, attempt count, and consumed flag) and sessions (id is the sha256 of the cookie token, plus userid and expiry). Your existing users table needs only a unique case-insensitive email. Wire the mailer using the plain smtpfa.st REST API — no SDK — with fallback logging for development. Copy the two server actions (requestLoginCode and verifyLoginCode) that mint six-digit codes, mail them, verify them with timing-safe comparison and per-code attempt caps, and create accounts for first-time emails. Use the provided form component (EmailOtpForm) which handles the two-step UX: email entry, then six code boxes with autofill, paste fan-out, and resend. Wrap your protected routes with getCurrentUser() to check sessions. The whole system is paranoid by design — read references/security-model.md to understand each defense and why it matters before you adapt anything.
More from @generalized
Full shelf →Design
Build the interactive dashboard UI (calendar, modals, chat, settings) on top of a working backend, consuming its contracts without re-implementing logic.
Design
Build the marketing page for a web app with an existing foundation, using the brief's voice and design language to defeat generic AI output.
DevOps
Scaffold a Next.js web app foundation with Neon services, Drizzle schema, and design tokens—stops before building UI.