Curriculum1 of 8
+ Module 17

Auth: Gating Pages Behind Login

Gate something behind login and understand sessions vs. tokens.

Phase 3 · Full stack · 8 sections · about 5 minutes

+ 01 / 08

Sessions vs. Tokens, The Core Difference

When you log in to a site, how does it remember you?

Two patterns exist. A session is server-side state. You sign in, the server stores "you are logged in" on its hard drive, and gives your browser a ticket (a cookie) that says "give this ticket back on every request." The server checks the ticket, looks up the state, and says "yep, you're real."

A token is self-contained state. You sign in, the server creates a signed package that says "this person is alice@example.com, this token is good until Tuesday, and I've signed it so nobody can forge it." Your browser stores it, usually in localStorage or a cookie. On every request, your browser sends the token, and the server verifies the signature and trusts the contents.

Sessions are simpler to revoke. Log out, the server throws away the state, the ticket is useless. Tokens are harder to revoke instantly, but they scale better because the server doesn't have to remember everyone.

The difference matters for how you think about your code.

+ 02 / 08

How Cookies and Browsers Work

A cookie is a small file your browser stores and automatically sends to a server on every request. The server can set it with a response header.

+ JavaScript
1Set-Cookie: sessionId=abc123; HttpOnly; Secure; Path=/; SameSite=Lax

The browser sees this header, stores sessionId=abc123, and from then on, every request to that domain includes Cookie: sessionId=abc123 without you doing anything.

This is the magic that makes sessions work. The server doesn't have to ask the browser for the ticket every time. The browser just sends it.

The HttpOnly flag means JavaScript can't read it, safer against XSS. The Secure flag means it only travels over HTTPS. SameSite=Lax stops the browser from sending it to cross-site requests.

Cookies are old, they're built into the browser, and they're the reason sessions work at all.

+ 03 / 08

DIY Sessions: The Hand-Written Detour

Build a minimal session system from scratch. Not for production, but to understand what's happening under the hood.

Store sessions in a JavaScript object. When someone signs in, generate a random ID, save it, and set a cookie.

+ JavaScript
1// Simple session store (in-memory; lost on server restart)
2const sessions = {};
3 
4export function createSession(userId) {
5 const sessionId = crypto.randomUUID();
6 sessions[sessionId] = { userId, createdAt: Date.now() };
7 return sessionId;
8}
9 
10export function getSession(sessionId) {
11 const session = sessions[sessionId];
12 if (!session) return null;
13 const age = Date.now() - session.createdAt;
14 if (age > 1000 * 60 * 60 * 24) return null; // 24 hours
15 return session;
16}
17 
18export function deleteSession(sessionId) {
19 delete sessions[sessionId];
20}

Now a login endpoint:

+ JavaScript
1// POST /api/login
2export async function POST(req) {
3 const { email, password } = await req.json();
4 
5 // (check password; find user)
6 const user = await db.users.findOne({ email });
7 if (!user || !verifyPassword(password, user.passwordHash)) {
8 return new Response("Invalid credentials", { status: 401 });
9 }
10 
11 const sessionId = createSession(user.id);
12 const response = new Response(JSON.stringify({ ok: true }), { status: 200 });
13 response.headers.set(
14 "Set-Cookie",
15 `sessionId=${sessionId}; HttpOnly; Secure; Path=/; SameSite=Lax`,
16 );
17 return response;
18}

On every protected request, check the cookie:

+ JavaScript
1// Middleware or handler
2export function getSessionFromRequest(req) {
3 const cookie = req.headers.get("cookie");
4 if (!cookie) return null;
5 
6 const sessionId = cookie
7 .split("; ")
8 .find((c) => c.startsWith("sessionId="))
9 ?.split("=")[1];
10 
11 if (!sessionId) return null;
12 return getSession(sessionId);
13}

This is the bones of every auth system. The server stores state, cookies carry the ticket, middleware validates on every request. Building it once teaches you why libraries exist.

+ 04 / 08

Tokens: The Stateless Upside

Tokens flip the model. Instead of "server stores state, browser carries a ticket," it's "browser carries the state, server verifies it."

A JWT (JSON Web Token) is the most common flavor. It's three base64-encoded parts joined by dots: header, payload, signature.

+ JavaScript
1// Signing a token
2import jwt from "jsonwebtoken";
3 
4const token = jwt.sign(
5 { userId: "alice-123", email: "alice@example.com" },
6 process.env.JWT_SECRET,
7 { expiresIn: "24h" },
8);
9// Result: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJhbGljZS0xMjMi...

The payload is readable by anyone, but the signature is a hash of the payload plus a secret only the server knows. If someone tampers with the payload, the signature no longer matches.

Upside: the server doesn't store anything. Every token is self-contained. If you have 1 million users, you don't need 1 million rows in a sessions table.

Downside: you can't revoke a token instantly. If someone steals a token, it stays valid until it expires. You can add a blocklist (a database of revoked tokens), but then you've lost the stateless advantage.

Tokens work well for APIs where clients are programs, not browsers with cookies. Your Next.js app usually sticks with sessions.

+ 05 / 08

Why Platforms Like Supabase Exist

Hand-written sessions work, but they're tedious and risky. Real login systems have to handle password hashing, email verification, password reset flows, MFA, detecting compromised tokens, rate-limiting login attempts, and more.

Supabase Auth is the platform play. You don't write any of the session logic. You call Supabase methods, it handles the database, the cookies, the expiration, the security headers.

+ JavaScript
1// Supabase does the work
2import { createClient } from "@supabase/supabase-js";
3 
4const supabase = createClient(URL, KEY);
5 
6// Sign up
7const { data, error } = await supabase.auth.signUp({
8 email: "alice@example.com",
9 password: "secret",
10});
11 
12// Sign in
13const { data, error } = await supabase.auth.signInWithPassword({
14 email: "alice@example.com",
15 password: "secret",
16});
17 
18// Get the current user (from the session)
19const {
20 data: { user },
21} = await supabase.auth.getUser();

Supabase handles session cookies, expiration, refresh tokens, all the security details. Your code shrinks to a few lines. The platform takes the burden.

The trade-off: you're dependent on Supabase. If it goes down, so does your auth. For a solo project or a startup, that's a reasonable bet.

+ 06 / 08

Building a Gated Page

Now the build. Create a page only logged-in users can see.

First, a layout that checks the session:

+ TypeScript
1// app/protected/layout.tsx
2import { redirect } from "next/navigation";
3import { createClient } from "@supabase/supabase-js";
4 
5export default async function ProtectedLayout({
6 children,
7}: {
8 children: React.ReactNode;
9}) {
10 const supabase = createClient(
11 process.env.NEXT_PUBLIC_SUPABASE_URL!,
12 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
13 );
14 
15 const {
16 data: { session },
17 } = await supabase.auth.getSession();
18 
19 if (!session) {
20 redirect("/signin");
21 }
22 
23 return <>{children}</>;
24}

The layout runs on every request to /protected/*. If there's no session, redirect to signin. If there is, render the page.

For a client-side check, use a hook:

+ TypeScript
1// app/dashboard/page.tsx
2"use client";
3import { useEffect } from "react";
4import { useRouter } from "next/navigation";
5import { useAuth } from "@/lib/hooks/useAuth";
6 
7export default function DashboardPage() {
8 const router = useRouter();
9 const { user, loading } = useAuth();
10 
11 useEffect(() => {
12 if (!loading && !user) {
13 router.push("/signin");
14 }
15 }, [user, loading, router]);
16 
17 if (loading) return <p>Loading...</p>;
18 if (!user) return null;
19 
20 return <div>Welcome, {user.email}</div>;
21}

The hook reads the session and redirects if it's missing. The pattern is simple: check, redirect if needed, render if OK.

+ 07 / 08

Sign-Out and Session Cleanup

Sign-out destroys the session. On the server:

+ TypeScript
1// app/api/logout/route.ts
2import { createClient } from "@supabase/supabase-js";
3 
4export async function POST() {
5 const supabase = createClient(
6 process.env.NEXT_PUBLIC_SUPABASE_URL!,
7 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
8 );
9 
10 await supabase.auth.signOut();
11 
12 // Hard redirect to homepage
13 return new Response(null, {
14 status: 303,
15 headers: { Location: "/" },
16 });
17}

Or on the client:

+ TypeScript
1// In a button or React component
2"use client";
3import { useRouter } from "next/navigation";
4import { createClient } from "@supabase/supabase-js";
5 
6export function SignOutButton() {
7 const router = useRouter();
8 const supabase = createClient(
9 process.env.NEXT_PUBLIC_SUPABASE_URL!,
10 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
11 );
12 
13 const handleSignOut = async () => {
14 await supabase.auth.signOut();
15 router.push("/");
16 };
17 
18 return <button onClick={handleSignOut}>Sign Out</button>;
19}

signOut() clears the session cookie, invalidates the token, and wipes localStorage. The browser is now anonymous again. Any gated page will redirect.

Test the flow: log in, navigate to a protected page, refresh (session persists), sign out, refresh (redirected back to signin). That's auth.

+ 08 / 08

Learn it by hand once, then delegate it

The point of this whole module: you could hand-write sessions, but why would you?

Hand-writing sessions teaches you why platforms exist. You learn that password hashing is non-obvious, that token expiration needs thought, that cookies have security flags for a reason. That knowledge matters.

But once you know it, delegating to Supabase is the right call. The platform has security experts, it's audited, it handles edge cases you'd miss. Your job is to gate pages, not to invent login.

This pattern repeats everywhere in software: learn the hard way once, then choose the simple platform when you actually build something. The trade-off is always the same, less code and more safety, in exchange for less control. And for auth, that's a deal worth taking.

+ Up nextProfessional Practice & DeployPreviouslyDatabases & ORM