DEV Community

Roberto Luna
Roberto Luna

Posted on

Cookie‑Based Session Authentication in a Next.js 13 App (tvview)

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:

  1. Browser prompts – Users saw the native “username/password” dialog, which is a terrible UX.
  2. Statelessness – Every request had to carry the Authorization header, 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"
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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:password to use as a simple token (good enough for a demo, replace with JWT later).
  • Provides a computeSessionToken function and a getConfiguredCredentials loader 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",
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)