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 most of the interesting work isn't the auth itself; it's the small defenses around 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. That's the whole vocabulary.
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. One file, one gate, 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.
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.
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.
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. With a server-held secret keyed into the hash, the precomputation attack falls apart. An attacker without the secret can't build the table.
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.
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.
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.
If your single-user auth ended up somewhere different, particularly the rate-limit story, @roland.leth.ro is the fastest place to tell me about it.