Auth for a single-user site, without NextAuth
I'm the only user on this site. There's no signup, no password reset, no OAuth providers, no "log in with GitHub" button. Just me, a cookie, and a login page nobody else has any reason to visit. So I didn't reach for NextAuth. It would have done what I needed plus thirty things I don't. The auth layer ended up at about 200 lines, and I want to walk through them, because the small defenses around the auth took more thought than the auth itself.
The shape of it
// src/lib/auth.ts
const SESSION_DURATION = 60 * 60 * 24 * 7 // 7 days
export async function createSession(): Promise<void> {
const token = await new SignJWT({ admin: true }) // ❶
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${SESSION_DURATION}s`)
.sign(getSessionSecret())
const cookieStore = await cookies()
cookieStore.set(COOKIE_NAME, token, { // ❷
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: SESSION_DURATION,
path: "/",
})
}jose signs an HS256 JWT with a single claim — admin: true ❶ — and the token goes into an httpOnly, sameSite: "lax" cookie ❷. Passwords are bcryptjs-hashed at 10 rounds, and that's all of it.
The gate sits in proxy.ts:
// src/proxy.ts (excerpt)
const isAdminApi =
pathname === "/api/admin" ||
pathname.startsWith("/api/admin/")
const isAdminPage =
pathname.startsWith("/admin") && pathname !== "/admin/login" // ❶
if (isAdminApi || isAdminPage) {
const isAuthed = await isAuthenticated(request)
if (!isAuthed) {
if (isAdminApi) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const loginUrl = request.nextUrl.clone()
loginUrl.pathname = "/admin/login"
return NextResponse.redirect(loginUrl)
}
}Two prefix checks (/api/admin/*, then /admin/* minus the login page itself), then a JWT verify on the cookie. If you're not authed, API routes return JSON 401, pages redirect to /admin/login. It's one file and one gate, with no decorators or per-route boilerplate.
The one subtle bit is why both checks exist ❶. The trailing slash in startsWith("/api/admin/") keeps the prefix tight: drop it and startsWith("/api/admin") also matches /api/administrators and friends. But the trailing slash leaves bare /api/admin (no trailing slash) slipping through into the generic /api/* pass-through, so the exact-match pathname === "/api/admin" closes that gap. No route currently lives there, but the explicit guard is cheap defense in depth.
Timing-resistant credential checks
The verify-credentials function does two things that both fall out of the same principle: every code path should take the same amount of time, regardless of whether you're a real user or a probe.
// src/lib/auth.ts
const dummyHash = bcrypt.hashSync("dummy", 10) // ❶
export async function verifyCredentials(
email: string,
password: string
): Promise<boolean> {
const credentials = getAdminCredentials()
const hash =
credentials !== null
? Buffer.from(credentials.passwordHash, "hex").toString()
: dummyHash // ❷
// Always run bcrypt regardless of email match or credential presence,
// to prevent timing-based user enumeration / config-state probing.
const passwordMatch = await bcrypt.compare(password, hash) // ❸
if (credentials === null) {
return false
}
return email === credentials.email && passwordMatch
}The dummy hash is computed once at module load ❶. If credentials aren't configured — fresh deploy, missing env var — we still hash the submitted password against the dummy ❷. Without this, a probe can tell "admin not configured yet" from the response timing, which is small but real. The same idea drives the unconditional bcrypt.compare ❸: even when the email doesn't match, the bcrypt cost still gets paid.
The dummy hash isn't load-bearing for me. Nobody's running timing attacks on my login. It is load-bearing the moment this pattern gets copy-pasted into a multi-tenant codebase, where "is this tenant configured?" is exactly the state an attacker wants to probe. The shape of the function — unconditional hash, equality check at the end — is the same either way; what changes is whether the early-return version would have leaked anything that mattered.
Per-IP rate limiting, fail-open
The login route is the only public auth entry point, which gives its rate limit one constraint the rest of the app doesn't have: it can't lock me out.
// src/app/api/auth/login/route.ts
const ratelimit =
redisConfig !== null && ipHashSecret !== null
? new Ratelimit({
redis: new Redis(redisConfig),
limiter: Ratelimit.slidingWindow(5, "15 m"), // ❶
prefix: "rl:login",
})
: null5 attempts per 15 minutes per IP ❶. My initial version had a single global bucket, which had a fun failure mode: a stale botnet running 5 failed attempts every 15 minutes could lock me out of the admin. Per-IP keying fixed that: each origin gets its own budget, the botnet eats its own quota.
The fail-open branch matters more than it looks:
try {
const { success } = await ratelimit.limit(key)
if (!success) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 })
}
} catch (error) {
// Fail-open: a transient Upstash blip should not lock the admin out.
console.warn("[api:auth:login] rate limit unavailable, failing open", error)
}If Upstash goes sideways — a network blip, an idle-tier eviction, a regional outage — the limiter throws. Failing closed (rejecting all logins until Redis recovers) means Redis health gates me getting in. Failing open means a brief window where the limiter is off, which I'd much rather have than "can't log in to fix Redis because Redis is broken."
I keep the warning at console.warn, not console.error, for the same reason the rate-limited responses do: routine adversarial signal shouldn't dominate the error log. A botnet hitting the limiter is expected; the error budget is for real bugs.
The IP pseudonymization story
This is the part of the auth work I had the most fun with, and also the part that's most over-engineered for the actual threat model.
The rate limiter needs to bucket requests per IP. The naive implementation uses the IP itself as the bucket key. That's a problem for two reasons.
First, privacy. An IP address is identifying information: the kind of data you want to think twice about writing in plain anywhere, particularly to a third-party Redis. The bucket key doesn't need the actual IP; it just needs something stable per IP. So that's what it should store.
Second, the part I learned writing this: the obvious-looking fix — "just SHA-256 the IP" — doesn't work the way you might hope. The IPv4 keyspace is about 4 billion addresses. Hashing one IP is a few microseconds; hashing all of them is a few hours of laptop time. A plain SHA-256 of an IP is reversible in practice. Anybody who lifts the Redis contents has a rainbow table.
HMAC fixes this: key the hash with a server-held secret and the precomputation attack falls apart, because the table can't be built without the secret.
function clientBucketKey(request: NextRequest, secret: string): string {
const forwarded = request.headers.get("x-forwarded-for")
let rawIp = "unknown"
if (forwarded != null && forwarded !== "") {
const first = forwarded.split(",")[0]?.trim() // ❶
if (first !== undefined && first !== "") {
rawIp = first
}
}
return createHmac("sha256", secret) // ❷
.update(rawIp)
.digest("base64url")
.slice(0, 22)
}Leftmost segment of x-forwarded-for ❶: that's the original client on Vercel; the rest is the proxy chain. HMAC-SHA256 with the secret ❷, base64url-encoded, truncated to 22 chars because Upstash bucket keys don't need to be full hashes to be unique enough.
Then the matching guardrail at module load: if Redis is configured but IP_HASH_SECRET isn't, the rate limiter goes off entirely — better than regressing to plain-IP buckets silently — and a console.warn fires so the gap is visible in function logs:
if (redisConfig !== null && ipHashSecret === null) {
console.warn(
"[api:auth:login] IP_HASH_SECRET not configured, rate limiting disabled"
)
}Is any of this load-bearing for this site? No. The Redis instance has my own IPs and a few bot IPs in it; nobody is rainbow-table-ing those buckets to deanonymize anyone. It starts mattering the moment the rate-limit bucket is tied to user actions and someone with read access to the storage layer could correlate them. Most consumer products live in that regime; mine doesn't.
Bearer auth for cron
The site has a Vercel cron job hitting /api/cron/ping once a day. Upstash's free tier marks a Redis database as idle if it goes about a week without a real data command (PING doesn't count, which is the kind of footgun you only learn from the email about your database being paused), so the cron writes a single SET keepalive:last <iso> to dodge the eviction. The bearer token gets compared timing-safely:
function isAuthorized(auth: string | null, expected: string): boolean {
const expectedBuf = Buffer.from(`Bearer ${expected}`)
const authBuf = Buffer.from(auth ?? "")
if (authBuf.length !== expectedBuf.length) {
timingSafeEqual(expectedBuf, expectedBuf) // ❶
return false
}
return timingSafeEqual(authBuf, expectedBuf)
}timingSafeEqual requires equal-length buffers, which on its own would leak the expected length via the early return. The trick ❶ is to still run a same-length comparison when lengths differ, so the byte-level work stays constant-time. The expected length itself still leaks, but that's a server-configured secret, not something an attacker can usefully narrow.
One small thing about the failure paths: when CRON_SECRET isn't configured at all, the route returns 401 (the same status as an unauthorized caller), not 500 ("CRON_SECRET not configured"). A pre-auth probe shouldn't learn anything about the server's configuration state.
What's next
The next post is about the validation layer one step further inside: the helper that runs Zod on every request body and the rules I picked for what it's allowed to log. There's some thematic overlap with the credential-handling above (both refuse to log submitted values), but the patterns diverged enough to deserve their own walkthrough.
The rate-limit story is the part I'd most like to be argued with about: @roland.leth.ro or @rolandleth.
The rest of the series is listed in The shape of a Next.js app.