Implementing Cookie‑Based Session Auth in a Next.js 13 App (Replacing HTTP Basic Auth)
TL;DR: I removed the insecure HTTP Basic Auth flow and introduced a cookie‑based session using Next.js 13 API routes, a tiny crypto helper, and middleware. The new approach stores a signed token in a pc_session cookie, validates it on every request, and lets the UI handle redirects without exposing credentials.
The Problem
Our pcview dashboard was protected only by HTTP Basic Auth configured in next.config.js. That worked for a quick demo, but it broke down as soon as we needed:
-
Cross‑origin API calls from the client‑side inventory pages (Displays, POS, Mobile Lines). Browsers strip the
Authorizationheader on XHR/fetch, causing 401 errors. - Logout capability – Basic Auth has no “log out” mechanism; the browser keeps the credentials cached until the tab is closed.
-
Fine‑grained middleware – We wanted to protect only the
/app/(dashboard)routes while leaving the public landing page untouched.
The symptom was a cascade of 401 responses when the React components tried to fetch inventory data after the initial page load. The console showed:
GET https://api.pcview.dev/api/displays/devices 401
Unauthorized
What I Tried First
My first attempt was to keep HTTP Basic Auth and simply forward the header from the client using fetch(..., { headers: { Authorization: ... } }). That failed because browsers block the Authorization header on cross‑origin requests unless the server explicitly allows it, and we didn’t want to expose the raw username/password in the client bundle.
I also tried to use NextAuth.js, but the project’s size and the fact that we only need a single static credential (configured in .env) made the full library overkill. I needed something lightweight, fully under my control, and compatible with the existing file‑based routing.
The Implementation
1. Auth Helpers (src/lib/auth.ts)
I created a small utility module that:
- Reads the static credentials from environment variables (
PCVIEW_USER,PCVIEW_PASS). - Generates a SHA‑256 hash of
username:passwordas the expected token. - Computes a session token by hashing the same string with a random nonce and a timestamp, then base64‑encoding it.
// src/lib/auth.ts
export const AUTH_COOKIE_NAME = "pc_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("");
}
// Pull static credentials from env (fallback to dev defaults)
export async function getConfiguredCredentials() {
const user = process.env.PCVIEW_USER ?? "admin";
const pass = process.env.PCVIEW_PASS ?? "admin123";
return { user, pass };
}
// Expected token = sha256("user:pass")
export async function getExpectedToken() {
const { user, pass } = await getConfiguredCredentials();
return sha256Hex(`${user}:${pass}`);
}
// Session token = base64(sha256("user:pass:nonce:ts"))
export async function computeSessionToken() {
const { user, pass } = await getConfiguredCredentials();
const nonce = crypto.randomUUID();
const ts = Date.now();
const raw = `${user}:${pass}:${nonce}:${ts}`;
const hash = await sha256Hex(raw);
return btoa(`${hash}:${nonce}:${ts}`);
}
2. Login API Route (src/app/api/login/route.ts)
The route receives a JSON body { username, password }, validates it against the static credentials, and sets a signed 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 { user, pass } = await getConfiguredCredentials();
if (username !== user || password !== pass) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const token = await computeSessionToken();
const response = NextResponse.json({ ok: true });
response.cookies.set(AUTH_COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24, // 1 day
});
return response;
}
3. Logout API Route (src/app/api/logout/route.ts)
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 response = NextResponse.json({ ok: true });
response.cookies.set(AUTH_COOKIE_NAME, "", {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 0,
});
return response;
}
4. Login Page (src/app/login/page.tsx)
A client‑side component that posts credentials, handles loading/error states, and redirects to the dashboard on success.
tsx
// src/app/login/page.tsx
"use client";
import { useState } 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") ?? "/app";
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
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();
throw new Error(data.error ?? "Login failed");
}
router.replace(redirect);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="flex h-screen items-center justify-center">
<form onSubmit={handleSubmit} className="max-w-sm space-y-4">
<h2 className="text-2xl font-bold flex items-center">
<Lock className="mr-2" /> Sign in to pcview
</h2>
{error && <p className="text-red-600">{error}</p>}
<input
required
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full p-2 border rounded"
/>
<input
required
type="password"
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full p-2 border rounded"
/>
<button
type="submit"
disabled={
---
*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/pcview` · 2026-08-19*
\#playadev #buildinpublic
Top comments (0)