Cookie‑Based Session Authentication in a Next.js 13 App (tvview)
TL;DR: Replaced the insecure HTTP Basic Auth with a real login portal that issues a signed cookie session. The change touches the API routes, a shared auth library, middleware, and the UI, giving us proper stateful authentication across the app.
The Problem
The original tvview prototype used HTTP Basic Auth for everything behind /api/*. It worked for quick demos, but two things broke us:
- Browser prompts – Users saw the native “username/password” dialog, which is a terrible UX.
-
Statelessness – Every request had to carry the
Authorizationheader, making CSRF protection and session revocation a nightmare.
The symptom showed up in the console as:
GET https://tvview.vercel.app/api/episodes 401
WWW-Authenticate: Basic realm="Secure Area"
Every protected page redirected to the login page, but the page itself could not read the auth state because the browser never sent the credentials after the first 401. We needed a proper session mechanism that works with Next.js 13’s App Router.
What I Tried First
My first attempt was to keep the Basic Auth flow but move the credential check into a custom middleware that would read Authorization from the request and set a flag on request.nextUrl. I added this snippet to src/middleware.ts:
export function middleware(request: NextRequest) {
const auth = request.headers.get('authorization')
if (!auth || !isValid(auth)) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
What went wrong?
- The middleware ran before the body of a POST request was parsed, so I couldn’t read JSON credentials.
- Browsers kept prompting for credentials on every 401, and there was no way to hide that dialog.
- The approach still required the client to store the base64 string, which is insecure.
I scrapped that idea and went back to the drawing board: a real login endpoint that creates a signed cookie.
The Implementation
1. Shared Auth Helpers (src/lib/auth.ts)
I created a tiny auth utility that:
- Stores the cookie name (
AUTH_COOKIE_NAME). - Generates a SHA‑256 hash of
username:passwordto use as a simple token (good enough for a demo, replace with JWT later). - Provides a
computeSessionTokenfunction and agetConfiguredCredentialsloader that reads env vars.
// src/lib/auth.ts
export const AUTH_COOKIE_NAME = "tv_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("");
}
export async function computeSessionToken(username: string, password: string) {
const secret = `${username}:${password}`;
return await sha256Hex(secret);
}
// In a real project you’d pull these from a DB or secret manager
export function getConfiguredCredentials() {
return {
username: process.env.TVVIEW_USER ?? "admin",
password: process.env.TVVIEW_PASS ?? "admin123",
};
}
2. Login API Route (src/app/api/login/route.ts)
The new POST endpoint validates credentials, creates the token, and sets a 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 });
// Set a signed cookie that lasts 7 days
res.cookies.set({
name: AUTH_COOKIE_NAME,
value: token,
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
return res;
}
3. Logout API Route (src/app/api/logout/route.ts)
A tiny endpoint that 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({
name: AUTH_COOKIE_NAME,
value: "",
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 0, // expire immediately
});
return res;
}
4. Middleware (src/middleware.ts)
Now the middleware just checks for the presence of a valid session cookie. If missing, it redirects to /login.
// src/middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { AUTH_COOKIE_NAME, computeSessionToken, getConfiguredCredentials } from "@/lib/auth";
export async function middleware(request: NextRequest) {
const token = request.cookies.get(AUTH_COOKIE_NAME)?.value;
// Public routes that don’t need auth
const publicPaths = ["/login", "/api/login", "/api/logout", "/static"];
if (publicPaths.some(p => request.nextUrl.pathname.startsWith(p))) {
return NextResponse.next();
}
// If no token, redirect to login
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
// Verify token matches the configured credentials
const { username, password } = getConfiguredCredentials();
const expected = await computeSessionToken(username, password);
if (token !== expected) {
// Invalid token → clear cookie and redirect
const resp = NextResponse.redirect(new URL("/login", request.url));
resp.cookies.delete(AUTH_COOKIE_NAME);
return resp;
}
return NextResponse.next();
}
5. Login Page (src/app/login/page.tsx)
A client‑side form that posts to /api/login. I used React’s useState and useRouter for navigation, plus a Suspense fallback for the loading spinner.
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 searchParams = useSearchParams();
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");
}
// Redirect to original destination or home
const redirect = searchParams.get("next") ?? "/";
router.push(redirect);
} catch (err: any) {
setError(err.message);
} finally {
setLoading
---
*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/tvview` · 2026-08-19*
\#playadev #buildinpublic
Top comments (0)