DEV Community

Roberto Luna
Roberto Luna

Posted on

Replacing HTTP Basic Auth with a Cookie‑Based Session in a Next.js 13 App

Replacing HTTP Basic Auth with a Cookie‑Based Session in a Next.js 13 App

TL;DR: I swapped the legacy HTTP Basic Auth flow for a proper login portal that issues a signed cookie (cv_session). The change required new API routes, a client‑side login page, and middleware that validates the session token on every request.


The Problem

Our internal dashboard was protected by HTTP Basic Auth configured in next.config.js. It worked, but:

  • No logout – browsers cache credentials, forcing a full tab close to “log out”.
  • No session expiration – credentials were sent on every request, exposing them to man‑in‑the‑middle attacks if TLS ever failed.
  • Poor UX – the browser dialog is clunky and we couldn’t display custom error messages or redirect after login.

The symptom was a 401 Unauthorized response from the server when a request lacked the Authorization header, and we had no way to invalidate a session without restarting the dev server.


What I Tried First

My first attempt was to keep the Basic Auth header but generate it dynamically from a login form. I added a /login page that collected username/password and then called btoa() to build the header, forwarding it to the protected API routes.

// pseudo‑code from the first try
const token = btoa(`${username}:${password}`);
fetch('/api/secret', { headers: { Authorization: `Basic ${token}` } });
Enter fullscreen mode Exit fullscreen mode

What went wrong:

  • The header was still sent on every request, so the server kept prompting the browser’s built‑in auth dialog.
  • Next.js’ edge runtime stripped the Authorization header for security reasons, resulting in Missing required header: authorization.
  • We still couldn’t clear the header on logout, so the “log out” button was meaningless.

At that point I realized we needed a real session mechanism that lives in a cookie and is validated server‑side.


The Implementation

1. Auth utilities (src/lib/auth.ts)

I introduced a tiny auth helper that creates a SHA‑256 hash of the user’s credentials and stores it as a signed cookie.

// src/lib/auth.ts
export const AUTH_COOKIE_NAME = "cv_session";

async function sha256Hex(input: string): Promise<string> {
  const data = new TextEncoder().encode(input);
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
  return Array.from(new Uint8Array(hashBuffer))
    .map(b => b.toString(16).padStart(2, "0"))
    .join("");
}

/**
 * Returns the token we expect for the configured credentials.
 * In a real app this would query a DB or an external IdP.
 */
export async function getExpectedToken(): Promise<string> {
  const { username, password } = getConfiguredCredentials();
  return sha256Hex(`${username}:${password}`);
}

/**
 * Computes a session token for a given username/password pair.
 * The token is just a SHA‑256 hash; for demo purposes only.
 */
export async function computeSessionToken(username: string, password: string) {
  return sha256Hex(`${username}:${password}`);
}

/**
 * Hard‑coded credentials for the demo (replace with DB later).
 */
export function getConfiguredCredentials() {
  return { username: "admin", password: "supersecret" };
}
Enter fullscreen mode Exit fullscreen mode

Note: This is not production‑grade security. It’s a simple, reproducible example that lets us focus on the cookie flow without pulling in a full auth provider.

2. Login API route (src/app/api/login/route.ts)

The POST handler validates the payload against the configured credentials, creates a session token, and sets it in an HttpOnly cookie.

// src/app/api/login/route.ts
import { NextResponse } from "next/server";
import {
  AUTH_COOKIE_NAME,
  computeSessionToken,
  getConfiguredCredentials,
} from "@/lib/auth";

export async function POST(req: Request) {
  const { username, password } = await req.json();

  const { username: validUser, password: validPass } = getConfiguredCredentials();

  if (username !== validUser || password !== validPass) {
    return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
  }

  const token = await computeSessionToken(username, password);

  const res = NextResponse.json({ ok: true });
  // Secure flag is omitted for local dev; enable in production.
  res.cookies.set(AUTH_COOKIE_NAME, token, {
    httpOnly: true,
    path: "/",
    sameSite: "lax",
    maxAge: 60 * 60 * 24, // 1 day
  });

  return res;
}
Enter fullscreen mode Exit fullscreen mode

3. Logout API route (src/app/api/logout/route.ts)

Logging out simply clears the cookie.

// src/app/api/logout/route.ts
import { NextResponse } from "next/server";
import { AUTH_COOKIE_NAME } from "@/lib/auth";

export async function POST() {
  const res = NextResponse.json({ ok: true });
  res.cookies.set(AUTH_COOKIE_NAME, "", {
    httpOnly: true,
    path: "/",
    sameSite: "lax",
    maxAge: 0, // expire immediately
  });
  return res;
}
Enter fullscreen mode Exit fullscreen mode

4. Client‑side login page (src/app/login/page.tsx)

A minimal React component that posts credentials to /api/login. I used useRouter for navigation and a loading spinner from lucide-react.

// src/app/login/page.tsx
"use client";

import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Loader2, Lock } from "lucide-react";

export default function LoginPage() {
  const router = useRouter();
  const search = useSearchParams();
  const redirect = search.get("next") ?? "/";

  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError("");

    const res = await fetch("/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ username, password }),
    });

    if (!res.ok) {
      const data = await res.json();
      setError(data.error ?? "Login failed");
      setLoading(false);
      return;
    }

    router.push(redirect);
  };

  return (
    <div className="flex h-screen items-center justify-center">
      <form onSubmit={handleSubmit} className="max-w-sm w-full space-y-4">
        <h2 className="text-2xl flex items-center gap-2">
          <Lock size={24} /> Sign in
        </h2>

        {error && <p className="text-red-600">{error}</p>}

        <input
          required
          placeholder="Username"
          value={username}
          onChange={e => setUsername(e.target.value)}
          className="input"
        />
        <input
          required
          type="password"
          placeholder="Password"
          value={password}
          onChange={e => setPassword(e.target.value)}
          className="input"
        />

        <button
          type="submit"
          disabled={loading}
          className="btn-primary w-full flex items-center justify-center gap-2"
        >
          {loading ? <Loader2 className="animate-spin" /> : "Log in"}
        </button>
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

5. Updating the sidebar navigation (src/components/layout/sidebar.tsx)

The sidebar now uses useRouter to programmatically navigate after logout.


tsx
// src/components/layout/sidebar.tsx
"use client";

import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";

export default function Sidebar() {
  const router = useRouter();
  const pathname = usePathname();

  const handleLogout = async () => {
    await fetch("/api/logout", { method: "POST" });
    router.refresh(); // forces middleware to re‑run without a session
    router.push("/login");
  };

  return (
    <nav className

---

*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*

*Repo: `zaerohell/craveview` · 2026-08-19*

\#playadev #buildinpublic
Enter fullscreen mode Exit fullscreen mode

Top comments (0)