Full Stack Developer

Khalid.dev

How I Secured My Next.js API Routes with proxy.ts and httpOnly Cookies

Muhammad Khalid Chouhan 4 min read 1 views
How I Secured My Next.js API Routes with proxy.ts and httpOnly Cookies

When I first built the admin panel for this portfolio, I made a mistake a lot of developers make: I hid the admin page behind an unusual URL and assumed that was enough. It wasn't. The page was hidden, but the API routes behind it were wide open. Anyone who opened the Network tab could create, edit or delete my blogs and projects, and even read every message people sent through my contact form.

Here is how I fixed it, and what I learned along the way.

The real problem: the UI is not the security boundary

A secret page name, a hidden button or a NEXT_PUBLIC_ token feels like protection, but none of it is:

  • Hidden URLs get found: browser history, shared screenshots, bots scanning common paths.
  • NEXT_PUBLIC_ variables are bundled into the JavaScript that every visitor downloads. A "secret" token there is public.
  • The API is what matters. If DELETE /api/blog works without proof of who you are, your data is unprotected, whatever the UI looks like.

So the rule I follow now: every request that changes data, or reads private data, must be checked on the server.

One place to check everything: proxy.ts

Next.js 16 renamed middleware.ts to proxy.ts. It runs before your routes, which makes it the perfect single gate. Instead of adding an auth check to every route file (and eventually forgetting one), I describe the rules once:

const needsAdmin = (method: string, path: string) => {
  if (path.startsWith("/api/upload")) return true;
  if (path.startsWith("/api/contact")) return method !== "POST";
  if (path.startsWith("/api/blog") || path.startsWith("/api/projects")) {
    return !["GET", "HEAD", "PATCH"].includes(method); // reading stays public
  }
  return false;
};

export async function proxy(req: NextRequest) {
  const { method } = req;
  const path = req.nextUrl.pathname;
  if (needsAdmin(method, path) && !(await isAdmin(req.cookies.get("admin_session")?.value))) {
    return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 });
  }
  return NextResponse.next();
}

export const config = { matcher: "/api/:path*" };

Visitors can still read blogs and send a contact message. Everything else needs a valid session.

Sessions with an httpOnly cookie

For a single-admin portfolio I didn't need a full auth provider. The login route compares the password with an environment variable and, if it matches, sets a cookie:

response.cookies.set("admin_session", token, {
  httpOnly: true,                                 // JavaScript can't read it, so XSS can't steal it
  secure: process.env.NODE_ENV === "production", // HTTPS only in production
  sameSite: "strict",                             // not sent on cross-site requests, which blocks CSRF
  path: "/",
  maxAge: 60 * 60 * 24 * 7,                       // one week
});

The token is a SHA-256 hash of the password plus a separate ADMIN_SECRET, so the raw password is never stored in the browser. Changing either value logs every session out.

Because the cookie is sent automatically on same-origin requests, my frontend code didn't need to change at all: no headers to attach, no token in localStorage.

Compare secrets in constant time

A normal === stops at the first character that differs, so in theory response timing can leak how much of a guess was right. A constant-time comparison always checks the full string:

export const safeEqual = (a: string, b: string) => {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
};

It's a tiny function, and it's the right default for any secret.

Rate limiting: protect your inbox and your wallet

Auth stops strangers from changing data, but public endpoints can still be abused. My contact form sends me an email, and my chatbot calls a paid AI API. A simple loop could flood my Gmail or burn my credits. So the same proxy also limits requests per IP:

EndpointLimit
Admin login5 per 15 minutes
Contact form5 per hour
Newsletter subscribe3 per hour
Chatbot20 per 10 minutes

My limiter is in-memory, which means each server instance counts separately. That's fine for a portfolio. For a bigger app I would move it to Redis (for example Upstash) so every instance shares the same count.

Small things that also matter

  • Validate input on the server, even if the form already validates it. The API is the trust boundary.
  • Escape user input before putting it in HTML emails. Otherwise someone can inject links into your inbox through the contact form.
  • Don't swallow errors. My database helper used to console.log connection errors and carry on, which turned a wrong password into a slow, confusing 500 everywhere. Throwing the error made the real cause visible immediately.

Key takeaways

  1. The server is the security boundary. Hidden pages and public tokens are not.
  2. Put auth rules in one place (proxy.ts) so a new route can't silently skip them.
  3. Use httpOnly + sameSite cookies for sessions instead of tokens in localStorage.
  4. Rate limit anything public that costs you money or sends email.

Security work isn't glamorous and nobody sees it, but it is the difference between a portfolio and a liability. If you're building your own admin panel, open your Network tab, copy a request as cURL and run it from a terminal. If it works without logging in, you have the same problem I had.