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}` } });
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
Authorizationheader for security reasons, resulting inMissing 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" };
}
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;
}
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;
}
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>
);
}
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
Top comments (0)