This is part 4. Today: login. Form, server endpoint, session cookie, CSRF, and a real look at timing defense. This is the endpoint attackers probe the most, so it is the one we harden the hardest.
The flow
+-------+ +--------------+ +----------+
|Browser| POST | Next.js | SELECT | Postgres |
| | email | /api/auth/ | user by | users |
| | password | login | email | |
| |----------->| |----------->| |
| | | |<-----------| |
| | | verify | +----------+
| | | password |
| | | (bcrypt) |
| | | | INSERT +----------+
| | | if ok, | session | sessions |
| | | create |----------->| |
| | | session |<-----------| |
| | | | +----------+
| | Set-Cookie| |
| |<-----------| |
| | 302 / | |
+-------+ +--------------+
And then, on every subsequent request:
+-------+ GET /dashboard +--------------+
|Browser| Cookie: session=abc123 | Next.js |
| |--------------------------->| middleware |
| | | | SELECT
| | | |-->sessions
| | | |<--where token_hash=..
| | | |
| |<---- 200 with user context-| |
+-------+ +--------------+
Two things to notice:
The session token on the wire is not stored in the database. We store its SHA-256 hash. This is the same pattern as reset tokens. If the database leaks, the leaked rows cannot be replayed as cookies.
The verify path is a single row lookup on a hash. Fast. No cryptographic operation per request, just an index hit.
The session table
Same note as last article: if you are using kavachOS, pnpm kavachos migrate creates and maintains this table for you. I am showing the SQL so you know what is in your database either way.
From article 02, for reference:
create table sessions (
id bigserial primary key,
user_id bigint not null references users(id) on delete cascade,
token_hash text not null unique,
expires_at timestamptz not null,
last_used_at timestamptz not null default now(),
user_agent text,
ip_address inet,
created_at timestamptz not null default now()
);
create index idx_sessions_user on sessions(user_id);
create index idx_sessions_expires on sessions(expires_at);
The user_agent and ip_address columns are for the "active sessions" screen on a user's account page ("signed in on iPhone in San Francisco"). They are not used for validation. Do not tie session validity to IP. Phone carriers rotate IPs and your users will hate you.
The login form
// app/auth/login/page.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function Login() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(true);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setPending(true);
setError(null);
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password, remember }),
});
setPending(false);
if (res.status === 429) {
setError("Too many attempts. Try again in a minute.");
return;
}
if (!res.ok) {
setError("Email or password is incorrect.");
return;
}
router.push("/dashboard");
}
return (
<form onSubmit={submit} className="max-w-sm mx-auto mt-16 space-y-4">
<h1 className="text-2xl font-semibold">Sign in</h1>
<label className="block">
<span className="text-sm">Email</span>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded border p-2 mt-1"
autoComplete="email"
/>
</label>
<label className="block">
<span className="text-sm">Password</span>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded border p-2 mt-1"
autoComplete="current-password"
/>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
/>
Stay signed in for 30 days
</label>
{error && <p className="text-red-600 text-sm">{error}</p>}
<button
type="submit"
disabled={pending}
className="w-full rounded bg-black text-white py-2 disabled:opacity-50"
>
{pending ? "Signing in..." : "Sign in"}
</button>
<div className="flex justify-between text-sm">
<a href="/auth/forgot" className="underline">Forgot password?</a>
<a href="/auth/register" className="underline">Create account</a>
</div>
</form>
);
}
One error message for bad credentials: "Email or password is incorrect." Never "email not found" vs "password wrong". Same reason as register. Treat every signed-out-ish endpoint as hostile.
The autoComplete="current-password" tells password managers to offer the existing stored password. This is the small UX detail that makes the difference between users loving password managers and fighting them.
The server endpoint
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import bcrypt from "bcrypt";
import { randomBytes, createHash } from "crypto";
import { db } from "@/db";
import { users, sessions } from "@/db/schema";
import { eq } from "drizzle-orm";
import { rateLimit } from "@/lib/rate-limit";
const body = z.object({
email: z.string().email().max(254),
password: z.string().min(1).max(256),
remember: z.boolean().optional(),
});
const FAKE_HASH =
"$2b$12$abcdefghijklmnopqrstuuAbCdEfGhIjKlMnOpQrStUvWxYz.abcdef";
export async function POST(req: NextRequest) {
const ip = req.headers.get("x-forwarded-for") ?? "unknown";
if (await rateLimit(`login:${ip}`, { max: 10, window: 60 })) {
return NextResponse.json({ error: "Too many attempts" }, { status: 429 });
}
const parsed = body.safeParse(await req.json());
if (!parsed.success) {
await bcrypt.compare("dummy", FAKE_HASH);
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const email = parsed.data.email.toLowerCase().trim();
const user = await db.query.users.findFirst({
where: eq(users.email, email),
});
const hashToCompare = user?.passwordHash ?? FAKE_HASH;
const passwordOk = await bcrypt.compare(parsed.data.password, hashToCompare);
if (!user || !passwordOk) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
if (!user.emailVerified) {
return NextResponse.json(
{ error: "Please verify your email before signing in" },
{ status: 403 },
);
}
const rawToken = randomBytes(32).toString("base64url");
const tokenHash = createHash("sha256").update(rawToken).digest("hex");
const expiresAt = new Date(
Date.now() + (parsed.data.remember ? 30 : 1) * 24 * 60 * 60 * 1000,
);
await db.insert(sessions).values({
userId: user.id,
tokenHash,
expiresAt,
ipAddress: ip,
userAgent: req.headers.get("user-agent") ?? null,
});
const res = NextResponse.json({ ok: true });
res.cookies.set("session", rawToken, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
expires: expiresAt,
});
return res;
}
This endpoint is doing five things. Let me walk through the non-obvious ones.
The fake hash
const hashToCompare = user?.passwordHash ?? FAKE_HASH;
await bcrypt.compare(parsed.data.password, hashToCompare);
This is the timing defense nobody covers. bcrypt.compare takes ~50ms at cost factor 12. If you skip it when the user does not exist, you have a timing oracle: attackers can tell which emails are registered by measuring how long the endpoint takes. 50ms is plenty to detect over the network.
The fix is to always run bcrypt.compare, even when the user does not exist. The FAKE_HASH is a valid bcrypt string that will never match anything (I generated it once with bcrypt.hash("nothing", 12) and hardcoded it). The comparison takes the same ~50ms whether the user exists or not.
Measure this with time:
$ time curl -s -X POST http://localhost:3000/api/auth/login \
-H 'content-type: application/json' \
-d '{"email":"existing@example.com","password":"wrong"}'
real 0m0.082s
$ time curl -s -X POST http://localhost:3000/api/auth/login \
-H 'content-type: application/json' \
-d '{"email":"nonexistent@example.com","password":"wrong"}'
real 0m0.079s
3ms of noise. Good.
Without the fake hash:
$ time curl -s -X POST http://localhost:3000/api/auth/login \
-H 'content-type: application/json' \
-d '{"email":"existing@example.com","password":"wrong"}'
real 0m0.081s
$ time curl -s -X POST http://localhost:3000/api/auth/login \
-H 'content-type: application/json' \
-d '{"email":"nonexistent@example.com","password":"wrong"}'
real 0m0.022s
60ms difference. An attacker can now enumerate your user base with a wordlist.
The session cookie
res.cookies.set("session", rawToken, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
expires: expiresAt,
});
Four flags that all matter.
httpOnly: true means JavaScript on the page cannot read the cookie. This is your main defense against XSS-based session theft. An attacker who achieves script execution can still do damage, but they cannot grab the session token and walk away with it.
secure: true means the cookie only goes over HTTPS. On localhost you need to flip this off during development or use https://localhost with a self-signed cert.
sameSite: "lax" means the cookie is sent on navigation from other sites but not on cross-site POSTs. This is most of your CSRF defense for free. The alternative is "strict", which also blocks cross-site GETs and breaks the "click a link from Gmail to your app" flow.
expires: expiresAt matches the session row. The cookie expires at the same time the server-side row does, so the browser does not keep sending a cookie the server will reject anyway.
The email verification gate
if (!user.emailVerified) {
return NextResponse.json(
{ error: "Please verify your email before signing in" },
{ status: 403 },
);
}
If you decide to enforce email verification (you should, most of the time), this is where it goes. The error is distinguishable from "invalid credentials" because the user has already proven they know the password at this point. Telling them the specific problem is fine.
If you want to allow limited access before verification (some apps let users in but gate features), move the check out of login into the middleware that guards the feature.
The session middleware
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { createHash } from "crypto";
import { db } from "@/db";
import { sessions, users } from "@/db/schema";
import { eq, gt } from "drizzle-orm";
export async function middleware(req: NextRequest) {
const token = req.cookies.get("session")?.value;
if (!token) return NextResponse.redirect(new URL("/auth/login", req.url));
const tokenHash = createHash("sha256").update(token).digest("hex");
const row = await db.query.sessions.findFirst({
where: and(
eq(sessions.tokenHash, tokenHash),
gt(sessions.expiresAt, new Date()),
),
with: { user: true },
});
if (!row) {
const res = NextResponse.redirect(new URL("/auth/login", req.url));
res.cookies.delete("session");
return res;
}
await db
.update(sessions)
.set({ lastUsedAt: new Date() })
.where(eq(sessions.id, row.id));
const res = NextResponse.next();
res.headers.set("x-user-id", String(row.userId));
return res;
}
export const config = {
matcher: ["/dashboard/:path*", "/api/me/:path*"],
};
One thing worth calling out: the middleware updates last_used_at on every request. This is what powers the "last active 5 minutes ago" display on the account page. If you are worried about write amplification on a high traffic app, batch the updates or throttle to once per minute per session.
Logout
// app/api/auth/logout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createHash } from "crypto";
import { db } from "@/db";
import { sessions } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function POST(req: NextRequest) {
const token = req.cookies.get("session")?.value;
if (token) {
const tokenHash = createHash("sha256").update(token).digest("hex");
await db.delete(sessions).where(eq(sessions.tokenHash, tokenHash));
}
const res = NextResponse.json({ ok: true });
res.cookies.delete("session");
return res;
}
Delete the session row, delete the cookie. Both. If you only delete the cookie, the session is still valid server-side, which means an attacker holding a stolen cookie can keep using it after the user hits "log out".
CSRF
SameSite=Lax on the cookie handles most CSRF. The edge case it does not handle is a GET that changes state (you should not have those) or a POST from a form on your own origin that was manipulated by XSS (XSS is its own problem).
If you want extra defense, add a double-submit CSRF token:
// On any state-changing endpoint, check:
const csrfCookie = req.cookies.get("csrf")?.value;
const csrfHeader = req.headers.get("x-csrf-token");
if (!csrfCookie || csrfCookie !== csrfHeader) {
return NextResponse.json({ error: "csrf" }, { status: 403 });
}
Set the cookie on session create, have your frontend read it (not HttpOnly) and attach it as a header on every mutating fetch. This is belt-and-suspenders protection and most apps do not need it, but if you are handling money or health data, add it.
Testing from the terminal
# Login with valid credentials
$ curl -i -c cookies.txt -X POST http://localhost:3000/api/auth/login \
-H 'content-type: application/json' \
-d '{"email":"alice@example.com","password":"Correct-Horse-1"}'
HTTP/1.1 200 OK
set-cookie: session=abc123...; Path=/; Expires=...; HttpOnly; Secure; SameSite=Lax
content-type: application/json
{"ok":true}
# Hit a protected route
$ curl -i -b cookies.txt http://localhost:3000/api/me
HTTP/1.1 200 OK
content-type: application/json
{"user":{"id":1,"email":"alice@example.com"}}
# Logout
$ curl -i -b cookies.txt -X POST http://localhost:3000/api/auth/logout
HTTP/1.1 200 OK
set-cookie: session=; Path=/; Max-Age=0
content-type: application/json
{"ok":true}
# Hit the protected route again
$ curl -i -b cookies.txt http://localhost:3000/api/me
HTTP/1.1 302 Found
location: /auth/login
Check the sessions table:
$ psql $DATABASE_URL -c "select user_id, expires_at, last_used_at from sessions;"
user_id | expires_at | last_used_at
---------+--------------------------+------------------------
1 | 2026-05-18 14:30:22+00 | 2026-04-18 14:32:10+00
And after logout:
$ psql $DATABASE_URL -c "select count(*) from sessions where user_id = 1;"
count
-------
0
The same thing in kavachOS
Install, migrate, configure
pnpm add kavachos @kavachos/nextjs
pnpm kavachos migrate
The migrate command creates the users, sessions, and all the other tables this series is covering. You do not write the SQL, and you do not own the upgrade path when a new column is added.
// auth.ts
import { kavachos } from "kavachos";
import { nextjsAdapter } from "@kavachos/nextjs";
export const auth = kavachos({
adapter: nextjsAdapter(),
database: process.env.DATABASE_URL!,
session: {
expiresIn: "1d",
rememberMeExpiresIn: "30d",
rolling: true,
cookie: { sameSite: "lax", secure: true, httpOnly: true },
},
providers: {
password: { minLength: 12 },
},
});
The login form
"use client";
import { useAuth } from "@kavachos/nextjs";
export default function Login() {
const { signIn, pending, error } = useAuth();
return (
<form action={signIn}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<label>
<input name="remember" type="checkbox" />
Stay signed in for 30 days
</label>
<button disabled={pending}>Sign in</button>
{error && <p>Email or password is incorrect.</p>}
</form>
);
}
The middleware
// middleware.ts
export { auth as middleware } from "@/auth";
export const config = {
matcher: ["/dashboard/:path*", "/api/me/:path*"],
};
Done. The fake hash comparison, the SameSite=Lax cookie, the session rotation on password change, the last_used_at updates, all there. Docs at kavachos.com/docs/login.
Four things I see go wrong
Returning "email not found" or "wrong password". Always the same error. You are giving attackers free information otherwise.
No fake hash on the no-user branch. The timing leak is real and measurable over the open internet.
Using SameSite=None because someone copied a config from Stack Overflow. None requires Secure and opens the cookie up to cross-site sending. Default to Lax.
Storing the raw session token in the database. Hash it. Always. This is the same rule as reset tokens, magic link tokens, everything.
Up next
Article 05: password reset. You already know most of the shape from register and login. The twist is session rotation on success and how to make the email survive spam filters.
Follow me here so the next one shows up in your feed.
If you have a login flow bug you cannot figure out, drop the symptom in the comments. I have probably seen it.
Gagan Deep Singh builds open source tools at Glincker. Currently working on kavachOS (open source auth for AI agents and humans) and AskVerdict (multi-model AI verdicts).
If this was useful, follow me on Dev.to or X where I post weekly.
Top comments (0)