---
title: "Auth for a single-user site, without NextAuth"
slug: auth-for-a-single-user-site-without-nextauth
section: tech
date: 2026-06-05T22:47:00.000Z
canonical: https://roland.leth.ro/blog/tech/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 most of the interesting work isn't the auth itself; it's the small defenses around it.

## The shape of it

```typescript
// 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`:

```typescript
// 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.

## 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.

```typescript
// 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.

```typescript
// 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",
      })
    : null
```

5 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:

```typescript
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. With a server-held secret keyed into the hash, the precomputation attack falls apart. An attacker without the secret can't build the table.

```typescript
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:

```typescript
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:

```typescript
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][next] 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.

[prev]: /blog/tech/the-shape-of-a-next-js-app "The shape of a Next.js app"
[next]: /blog/tech/input-validation-and-logs-that-dont-leak "Input validation and logs that don't leak"
[@roland.leth.ro]: https://bsky.app/profile/roland.leth.ro "@roland.leth.ro on Bluesky"
