Skip to main content

Hardening an Admin Login: IPs, Timing, and a JWT Cookie

A steel padlock on a weathered green door
Apr 15, 20264 min readNext.js, TypeScript, Security, Node.js, REST APIs, Vercel

The problem

My admin dashboards started life as a single password gate per surface: an audit page, a jobs page, a leads page, each one asking for a password, holding it in memory, and sending it as x-admin-password on every fetch. It worked fine, right up until it didn't, which was at about three dashboards and two subtle attacks I hadn't thought about yet.

Four things were wrong:

  1. The rate limiter keyed off x-forwarded-for, which any client can set on Vercel.
  2. The password check was password === process.env.TOOLS_ADMIN_PASSWORD. String equality leaks timing.
  3. Each dashboard prompted for the password separately, and every refresh re-prompted.
  4. The scraping service trusted inbound HTML from a third-party API without sanitizing it.

So the fix is four small changes: trust the right header, compare in constant time, mint one JWT cookie that every dashboard honors, and sanitize on write.

IP spoofing on Vercel

A login rate limiter needs some identifier for the caller, and the obvious choice is x-forwarded-for; but on a Vercel function that header is whatever the client injected, optionally with Vercel appending one entry of its own. A hostile client sends x-forwarded-for: 1.1.1.1, 2.2.2.2 and the server happily believes whichever entry it naively picks.

Vercel sets x-real-ip to the actual edge-observed address, so that's the one to reach for. When you do have to trust x-forwarded-for, the rightmost entry is the one the immediate upstream added; the left-hand entries are whatever the client felt like sending.

apps/root/src/app/api/tools/login/route.ts
function getClientIp(req: NextRequest): string {
  const real = req.headers.get('x-real-ip');
  if (real) return real.trim();
 
  const xff = req.headers.get('x-forwarded-for');
  if (xff) {
    const parts = xff
      .split(',')
      .map(p => p.trim())
      .filter(Boolean);
    if (parts.length > 0) return parts[parts.length - 1];
  }
  return 'unknown';
}

Prefer x-real-ip, fall back to the last x-forwarded-for entry, and never trust the first one. If the platform sets a canonical header for you, use it.

Timing-safe comparison

a === b on strings bails out the moment it hits a mismatched character, and over enough requests that response-time difference leaks the length of the correct prefix. Node ships a constant-time comparison in node:crypto for exactly this:

import { timingSafeEqual } from 'node:crypto';
 
function constantTimeEqual(a: string, b: string): boolean {
  const aBuf = Buffer.from(a, 'utf8');
  const bBuf = Buffer.from(b, 'utf8');
  if (aBuf.length !== bBuf.length) return false;
  return timingSafeEqual(aBuf, bBuf);
}
 
if (!constantTimeEqual(password, expected)) {
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

timingSafeEqual requires equal-length buffers, which is why the length guard has to come first. That guard does leak one bit (whether the lengths match), but I'm fine with that; what matters is that a character-by-character compare no longer does.

The FastAPI side of the house does the same thing with hmac.compare_digest:

apps/job-api/app/dependencies.py
import hmac
 
def _api_key_matches(presented: str | None, expected: str) -> bool:
    if not expected or not presented:
        return False
    return hmac.compare_digest(presented, expected)

Same principle, same constant-time primitive, just a different runtime. Both paths now refuse to leak.

The per-dashboard password gate scaled badly: every admin route rendered a PasswordGate, every refresh re-prompted, and every dashboard shipped its own copy of the auth state. The replacement is one route that mints a JWT cookie and one middleware that guards the whole admin tree.

The login route signs an HS256 JWT with jose, sets it as an httpOnly cookie, and returns:

apps/root/src/lib/adminSession.ts
export async function mintSessionToken(
  ttlSeconds = ADMIN_SESSION_TTL_SECONDS
): Promise<string> {
  return new SignJWT({ sub: 'tools-admin' })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime(Math.floor(Date.now() / 1000) + ttlSeconds)
    .sign(getSecret());
}
 
export function sessionCookieOptions(
  maxAgeSeconds = ADMIN_SESSION_TTL_SECONDS
) {
  return {
    name: ADMIN_SESSION_COOKIE,
    httpOnly: true,
    secure: process.env['NODE_ENV'] === 'production',
    sameSite: 'lax' as const,
    path: '/',
    maxAge: maxAgeSeconds,
  };
}

The session helper lives in lib/adminSession.ts and gets imported by both the login route and the proxy. The secret has to be at least 32 characters; the helper flat-out refuses to sign or verify with anything shorter.

Guarding the admin tree used to be a job for middleware.ts, but Next.js 16 moves per-request edge logic into proxy.ts, so the auth check slots in right at the top of the existing CSP proxy:

apps/root/src/proxy.ts
export async function proxy(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/tools/admin')) {
    const token = request.cookies.get(ADMIN_SESSION_COOKIE)?.value;
    if (!(await isValidAdminSession(token))) {
      const loginUrl = new URL('/tools/login', request.url);
      loginUrl.searchParams.set(
        'next',
        request.nextUrl.pathname + request.nextUrl.search
      );
      return NextResponse.redirect(loginUrl);
    }
  }
  // ... CSP header setup continues below
}

Every /tools/admin/* request either carries a valid JWT cookie or gets redirected to /tools/login?next=<original-url>, and the dashboards themselves stopped caring about auth entirely; they just fetch their data and assume the proxy already did the work.

The FastAPI service accepts either the existing x-api-key (for the cron poller, which has no session) or a bearer JWT signed with the same secret:

def verify_api_key_or_session(
    request: Request,
    key: str | None = Security(api_key_header),
    s: Settings = Depends(get_settings),
) -> str:
    if _api_key_matches(key, s.job_api_key):
        return "api-key"
    token = _extract_bearer_token(request)
    if token:
        try:
            payload = jwt.decode(token, s.admin_session_secret, algorithms=["HS256"])
        except jwt.PyJWTError:
            pass
        else:
            if payload.get("sub") == "tools-admin":
                return "session"
    raise HTTPException(status_code=401, detail="Unauthorized")

The cron poller stays API-key-only so it can run unattended, and everything behind the dashboard reuses the same JWT the browser already has.

Sanitize third-party HTML on write

The last fix has nothing to do with login, but it deserves the same attention. The scraper pulls job descriptions from Greenhouse as HTML, and "Greenhouse gives you clean HTML" is a comfortable assumption that happens to be wrong; a description can contain inline event handlers, arbitrary tags, and whatever the posting company's ATS editor decided to emit.

So sanitize on the way into the database, not on the way out:

apps/job-api/app/services/sanitize.py
import bleach
 
ALLOWED_TAGS = [
    "p", "br", "ul", "ol", "li", "strong", "em", "b", "i", "u", "a",
    "h1", "h2", "h3", "h4", "h5", "h6",
    "blockquote", "code", "pre", "span", "div",
]
ALLOWED_ATTRS = {"a": ["href", "title", "rel"], "span": [], "div": []}
ALLOWED_PROTOCOLS = ["http", "https", "mailto"]
 
def sanitize_html(raw: str) -> str:
    if not raw:
        return ""
    cleaned: str = bleach.clean(
        raw,
        tags=ALLOWED_TAGS,
        attributes=ALLOWED_ATTRS,
        protocols=ALLOWED_PROTOCOLS,
        strip=True,
    )
    return cleaned

strip=True removes disallowed tags entirely instead of escaping them into visible markup. Sanitizing on write means the database never holds a malicious string in the first place, and every consumer of the data inherits the safety without having to remember to ask for it.

The takeaway

Each fix is small on its own: a header name, a crypto primitive, a cookie, a sanitizer. What they have in common is that each one swaps a reasonable-looking default for the version that actually holds up under the attack it was written to defend against. The login went from "it works on localhost" to "it would survive a determined bot" in about two hundred lines of diff, and the JWT cookie made the UX better while it was at it.