Implementing a Real Login Portal with Cookie‑Based Sessions in a Next.js 13 App
TL;DR: Swapped out HTTP Basic Auth for a full‑stack login flow using a signed session cookie. The change required new API routes, a tiny auth library, and middleware to protect routes, all written in TypeScript for Next.js 13.
The Problem
Our greenview app was using HTTP Basic Auth as a quick way to lock the UI during early prototyping. The approach had two major issues:
-
Credentials were sent on every request – the browser automatically attached the
Authorizationheader, exposing the base64‑encoded username/password on the wire (even over HTTPS, it’s still not ideal). - No session state – we could not store user‑specific data (e.g., preferences) because each request was stateless.
The symptom showed up in the console as repeated 401 responses when the browser refreshed a protected page, and the network tab displayed the Authorization: Basic … header on every API call. I needed a proper login flow that persisted a session on the server side and let the client know when the user was authenticated.
What I Tried First
My first attempt was to keep the existing Basic Auth middleware and simply add a “Login” page that collected credentials and then called the same protected endpoints. I wrote a POST /api/login that verified the credentials against the hard‑coded values in src/lib/auth.ts and then returned a JSON object with a token. The client stored that token in localStorage and added it as a custom X‑Auth‑Token header on every request.
Why it failed
- CORS‑related headaches – Adding a custom header forced the browser to send a preflight OPTIONS request, which our Next.js edge middleware didn’t handle, resulting in 404s.
-
Token leakage – Storing the token in
localStoragemade it accessible to any script on the page, increasing XSS risk. -
Middleware mismatch – The existing Basic Auth middleware only looked for the
Authorizationheader, so the new token was never validated.
After a few frustrating debugging sessions (I kept seeing “Missing Authorization header” in the middleware logs), I decided to scrap the hybrid approach and go full‑cookie.
The Implementation
1. Auth helper (src/lib/auth.ts)
I created a tiny library that handles cookie naming, token generation, and credential checking. The file is completely self‑contained and has no external dependencies beyond the Node crypto API.
// src/lib/auth.ts
export const AUTH_COOKIE_NAME = "gv_session";
/**
* Returns the static credentials configured for the demo.
* In a real app you’d query a DB.
*/
export async function getConfiguredCredentials() {
// Hard‑coded for now, but you can load from env vars.
return { username: "admin", password: "s3cr3t" };
}
/**
* Simple SHA‑256 hash of "username:password" → hex string.
* Used as the session token; not production‑grade crypto.
*/
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("");
}
/**
* Generates a deterministic token from credentials.
* In production you’d use a random UUID + signed JWT.
*/
export async function computeSessionToken(username: string, password: string) {
return await sha256Hex(`${username}:${password}`);
}
/**
* Validates the incoming cookie token against the expected hash.
*/
export async function getExpectedToken() {
const { username, password } = await getConfiguredCredentials();
return computeSessionToken(username, password);
}
2. Login API route (src/app/api/login/route.ts)
The route receives a JSON body { username, password }, validates it, and sets an HTTP‑only 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) {
try {
const { username, password } = await req.json();
const { username: validUser, password: validPass } =
await 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 will be false in dev because we run on localhost.
res.cookies.set(AUTH_COOKIE_NAME, token, {
httpOnly: true,
path: "/",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 1 week
secure: process.env.NODE_ENV === "production",
});
return res;
} catch (err) {
console.error("Login error:", err);
return NextResponse.json({ error: "Bad request" }, { status: 400 });
}
}
3. Logout API route (src/app/api/logout/route.ts)
Clears the cookie by setting it with maxAge: 0.
// 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: "/",
maxAge: 0,
sameSite: "lax",
});
return res;
}
4. Front‑end login page (src/app/login/page.tsx)
A client component that posts credentials to /api/login and redirects 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 redirectTo = search.get("next") ?? "/";
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);
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (res.ok && data.ok) {
router.replace(redirectTo);
} else {
setError(data.error ?? "Login failed");
}
setLoading(false);
};
return (
<div className="max-w-md mx-auto mt-20 p-6 border rounded">
<h1 className="text-2xl mb-4 flex items-center">
<Lock className="mr-2" /> Sign in
</h1>
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="w-full p-2 border mb-3"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full p-2 border mb-3"
/>
{error && <p className="text-red-600 mb-2">{error
---
*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/greenview` · 2026-08-19*
\#playadev #buildinpublic
Top comments (0)