DEV Community

Roberto Luna
Roberto Luna

Posted on

Implementing a Real Login Portal with Cookie‑Based Sessions in a Next.js 13 App

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:

  1. Credentials were sent on every request – the browser automatically attached the Authorization header, exposing the base64‑encoded username/password on the wire (even over HTTPS, it’s still not ideal).
  2. 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 localStorage made it accessible to any script on the page, increasing XSS risk.
  • Middleware mismatch – The existing Basic Auth middleware only looked for the Authorization header, 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);
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

Top comments (0)