Phase 2: Basic Authentication — Part 2

Step 1: Actually Verifying the ID Token

Right now our callback route stores the ID token without checking anything about it — we're just trusting that whatever arrived is genuine. That's not acceptable even for a course project. Verifying a token means checking three separate things: that its cryptographic signature is genuine (nobody tampered with it), that it hasn't expired, and that it was issued by the Okta authorization server we expect, for our specific application.

We use jose for this because of a very specific Next.js constraint: Middleware runs on the Edge Runtime, a lightweight, browser-like environment — not full Node.js. That means Node's built-in crypto module (which older JWT libraries like jsonwebtoken depend on) isn't available there. jose is built on Web Crypto APIs instead, so the exact same verification code works in Middleware, Route Handlers, and Server Components alike.

Create src/lib/verifyToken.ts:


    import { jwtVerify, createRemoteJWKSet } from "jose";

    const JWKS = createRemoteJWKSet(
        new URL(`${process.env.OKTA_ORG_URL}/oauth2/default/v1/keys`)
    );

    export interface OktaIdTokenClaims {
        sub: string;
        email: string;
        name: string;
        [key: string]: unknown;
    }

    export async function verifyIdToken(idToken: string): Promise<OktaIdTokenClaims> {
        const { payload } = await jwtVerify(idToken, JWKS, {
            issuer: `${process.env.OKTA_ORG_URL}/oauth2/default`,
            audience: process.env.OKTA_CLIENT_ID,
        });

        return payload as OktaIdTokenClaims;
    }

Here's what each check does, concretely: createRemoteJWKSet fetches Okta's public signing keys from its /keys endpoint (and caches them), which jwtVerify uses to confirm the token's signature is genuine and untampered. The issuer check confirms the token was actually issued by our authorization server, not some other Okta org. The audience check confirms the token was issued for our specific application — without this check, a valid token issued to a completely different app on the same Okta org would incorrectly pass verification. jwtVerify also automatically rejects expired tokens by checking the exp claim — you don't need to check that yourself.

Now update the callback route from the last post — in src/app/api/auth/callback/route.ts, add the verification step right after you receive tokens:


    import { verifyIdToken } from "@/lib/verifyToken";

    // ... inside the same function, after: const tokens = await tokenResponse.json();

    try {
        await verifyIdToken(tokens.id_token);
    } catch (err) {
        console.error("ID token verification failed:", err);
        return NextResponse.redirect(new URL("/login?error=invalid_token", request.url));
    }

If this check ever fails in practice, it means something is seriously wrong (a misconfigured issuer, a tampered token, or a token meant for a different app) — never skip it, and never store tokens before this check passes.

Step 2: Logout

Logging a user out actually has two layers, and it's important to understand both:

  1. Your app's session — deleting the cookies we set, so your app itself no longer considers the user logged in.
  2. Okta's own session — the browser also holds a session cookie with Okta itself (that's what let you skip re-entering your password if you logged into a second app using the same Okta org). If you only clear your app's cookies, clicking "Log in with Okta" again would silently re-authenticate the user without even showing a login form, because Okta still remembers them.

A proper logout needs to end both. Create src/app/api/auth/logout/route.ts:


    import { NextRequest, NextResponse } from "next/server";

    export async function GET(request: NextRequest) {
        const idToken = request.cookies.get("id_token")?.value;

        const logoutUrl = new URL(`${process.env.OKTA_ORG_URL}/oauth2/default/v1/logout`);
        logoutUrl.searchParams.set("post_logout_redirect_uri", "http://localhost:3000");
        if (idToken) {
            // Okta requires the original id_token as a "hint" — proof this logout
            // request is tied to a real session it issued.
            logoutUrl.searchParams.set("id_token_hint", idToken);
        }

        const response = NextResponse.redirect(logoutUrl.toString());
        response.cookies.delete("id_token");
        response.cookies.delete("access_token");

        return response;
    }

We clear our own cookies first, then redirect the browser to Okta's /logout endpoint, which ends the Okta-side session too and sends the user back to post_logout_redirect_uri — which must exactly match one of the Sign-out redirect URIs you configured back in Phase 1, Step 2 (http://localhost:3000).

Add a logout link to the dashboard page from last time:


    <a href="/api/auth/logout" className="text-red-600 hover:underline">
        Log out
    </a>

Step 3: Protecting Routes with Middleware

Right now, /dashboard is visible to anyone, logged in or not — visiting the URL directly just shows the page, no check happens. Middleware fixes this by intercepting every matching request before it reaches your page.

Create src/middleware.ts at the root of src/:


    import { NextRequest, NextResponse } from "next/server";
    import { verifyIdToken } from "@/lib/verifyToken";

    export async function middleware(request: NextRequest) {
        const idToken = request.cookies.get("id_token")?.value;

        if (!idToken) {
            return NextResponse.redirect(new URL("/login", request.url));
        }

        try {
            await verifyIdToken(idToken);
            return NextResponse.next();
        } catch {
            // Token exists but is invalid or expired — treat as logged out.
            const response = NextResponse.redirect(new URL("/login", request.url));
            response.cookies.delete("id_token");
            response.cookies.delete("access_token");
            return response;
        }
    }

    export const config = {
        matcher: ["/dashboard/:path*"],
    };

The matcher config is important — it tells Next.js exactly which routes this Middleware should run on. We're scoping it to /dashboard and everything under it, so /login and our /api/auth/* routes are left untouched (protecting the login page itself would create an unbreakable redirect loop). As you add more protected pages later in the course (settings, admin panels), you'll extend this matcher array.

Try it now: log out, then try visiting http://localhost:3000/dashboard directly in the address bar. You should be bounced straight to /login — the page never even renders.

Step 4: Reading the Logged-In User in a Server Component

Middleware confirms that someone is logged in, but your dashboard page still doesn't know who. Let's read the verified identity directly inside the Server Component.

Update src/app/dashboard/page.tsx:


    import { cookies } from "next/headers";
    import { verifyIdToken } from "@/lib/verifyToken";
    import { redirect } from "next/navigation";

    export default async function DashboardPage() {
        const cookieStore = await cookies();
        const idToken = cookieStore.get("id_token")?.value;

        if (!idToken) {
            redirect("/login");
        }

        const claims = await verifyIdToken(idToken);

        return (
            <div className="p-8">
                <h1 className="text-2xl font-semibold text-slate-800">
                    Welcome, {claims.name}
                </h1>
                <p className="text-slate-500 mt-2">Logged in as {claims.email}</p>
                <a href="/api/auth/logout" className="text-red-600 hover:underline mt-4 inline-block">
                    Log out
                </a>
            </div>
        );
    }

Because this is a Server Component (the default for any file in app/ that doesn't say "use client"), this code runs entirely on the server, before any HTML reaches the browser. The id_token cookie is httpOnly, so it was never accessible to client-side JavaScript anyway — this is the correct, secure way to read it. Note we're verifying the token again here, separately from Middleware — that's intentional defense-in-depth, not redundant: Middleware protects the route from being reached at all, while this check ensures the page itself never renders content based on an unverified claim, even if it were somehow reached another way (e.g., a Server Action called directly).


What's Working Now

TaskFlow now has a complete, real authentication loop: a login button that redirects to Okta, a callback that exchanges the code for tokens and cryptographically verifies them, cookies storing the session securely, Middleware blocking unauthenticated access to /dashboard, proper two-layer logout, and a dashboard that displays the real logged-in user's name and email.

This closes out Phase 2.

In Phase 3, we'll cover what the Access Token and Refresh Token are actually for, silent token renewal so users aren't logged out every hour, and fetching/updating the user's full Okta profile.

Phase 2: Basic Authentication — Part 1

Theory is behind us. This is where TaskFlow gets a real, working "Log in with Okta" button. We're going to build the entire flow by hand: the login route, the callback route, and secure cookie-based token storage — so you understand every moving part, not just import a black-box function.

A Quick Design Decision, Explained

Since our Okta Application is registered as a Web Application (Phase 1, Step 2) — meaning it has a Client Secret it can keep private on the server — the correct place to run the token exchange (step 6–7 of the flow from Lecture 2) is inside a Next.js Route Handler, on the server, never in browser JavaScript. This is important: it means we generate the PKCE code verifier, redirect to Okta, and exchange the code for tokens entirely on the server side, using Node's built-in crypto module for the PKCE math and plain fetch calls to Okta's endpoints. We'll bring in @okta/okta-auth-js starting next lecture for token verification and profile/session helpers — but the actual redirect-and-exchange dance is clearer, and more correct for a confidential server-side app like ours, written directly against Okta's endpoints first. This also means you'll actually understand the flow instead of trusting a library to do it invisibly.

Step 1: Install What We Need


    npm install jose

That's it for now. jose is a small, well-maintained library for verifying signed JWTs (JSON Web Tokens) — we'll use it to verify Okta's ID token signature in the next post. We'll add @okta/okta-auth-js in Phase 3 when we cover silent token renewal and profile fetching, where it genuinely saves you work.

Step 2: A Small Helper for PKCE

Recall from Lecture 2: PKCE requires a random code verifier, and a hashed code challenge derived from it. Let's write that as a small utility.

Create src/lib/pkce.ts:


    import crypto from "crypto";

    export function generateCodeVerifier(): string {
        return crypto.randomBytes(32).toString("base64url");
    }

    export function generateCodeChallenge(verifier: string): string {
        return crypto
            .createHash("sha256")
            .update(verifier)
            .digest("base64url");
    }

    export function generateState(): string {
        return crypto.randomBytes(16).toString("base64url");
    }

The state value isn't part of PKCE itself — it's a separate, older CSRF protection that OAuth 2.0 has always recommended: a random value your app generates before redirecting, which Okta echoes back unchanged. If the state you receive on callback doesn't match what you sent, you reject the login — it means the request didn't originate from your app.

Step 3: The Login Route

Create src/app/api/auth/login/route.ts:


    import { NextRequest, NextResponse } from "next/server";
    import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/pkce";

    export async function GET(request: NextRequest) {
        const codeVerifier = generateCodeVerifier();
        const codeChallenge = generateCodeChallenge(codeVerifier);
        const state = generateState();

        const authorizeUrl = new URL(`${process.env.OKTA_ORG_URL}/oauth2/default/v1/authorize`);
        authorizeUrl.searchParams.set("client_id", process.env.OKTA_CLIENT_ID!);
        authorizeUrl.searchParams.set("response_type", "code");
        authorizeUrl.searchParams.set("scope", "openid profile email");
        authorizeUrl.searchParams.set("redirect_uri", "http://localhost:3000/api/auth/callback");
        authorizeUrl.searchParams.set("state", state);
        authorizeUrl.searchParams.set("code_challenge", codeChallenge);
        authorizeUrl.searchParams.set("code_challenge_method", "S256");

        const response = NextResponse.redirect(authorizeUrl.toString());

        // Store the verifier and state temporarily so the callback route can use them.
        // httpOnly means client-side JS can never read these — only the server can.
        response.cookies.set("pkce_verifier", codeVerifier, {
            httpOnly: true,
            secure: process.env.NODE_ENV === "production",
            sameSite: "lax",
            maxAge: 600, // 10 minutes — plenty of time to complete a login
            path: "/",
        });
        response.cookies.set("oauth_state", state, {
            httpOnly: true,
            secure: process.env.NODE_ENV === "production",
            sameSite: "lax",
            maxAge: 600,
            path: "/",
        });

        return response;
    }

Walk through what this does against the flow from Lecture 2: it's step 2 and step 3 combined. We build the /authorize URL with our Client ID, requested scopes, redirect URI, the state, and the code_challenge — then redirect the browser there. We temporarily stash the code_verifier and state in short-lived, httpOnly cookies, because our callback route (running as a separate request, possibly even a different server process) needs them again in step 6, and it has no other memory of this request.

Note notice /oauth2/default/ in the URL — that's us explicitly using the default Custom Authorization Server we configured in Phase 1, not the Org Authorization Server, exactly per the reasoning from Lecture 2.

Step 4: The Callback Route

Create src/app/api/auth/callback/route.ts:


    import { NextRequest, NextResponse } from "next/server";

    export async function GET(request: NextRequest) {
        const searchParams = request.nextUrl.searchParams;
        const code = searchParams.get("code");
        const returnedState = searchParams.get("state");
        const error = searchParams.get("error");

        if (error) {
            return NextResponse.redirect(new URL(`/login?error=${error}`, request.url));
        }

        const storedState = request.cookies.get("oauth_state")?.value;
        const codeVerifier = request.cookies.get("pkce_verifier")?.value;

        if (!code || !returnedState || returnedState !== storedState || !codeVerifier) {
            return NextResponse.redirect(new URL("/login?error=invalid_state", request.url));
        }

        // Exchange the authorization code for tokens — this is a direct
        // server-to-server request, never visible to the browser (step 6-7 from Lecture 2).
        const tokenResponse = await fetch(`${process.env.OKTA_ORG_URL}/oauth2/default/v1/token`, {
            method: "POST",
            headers: { "Content-Type": "application/x-www-form-urlencoded" },
            body: new URLSearchParams({
                grant_type: "authorization_code",
                client_id: process.env.OKTA_CLIENT_ID!,
                client_secret: process.env.OKTA_CLIENT_SECRET!,
                redirect_uri: "http://localhost:3000/api/auth/callback",
                code,
                code_verifier: codeVerifier,
            }),
        });

        if (!tokenResponse.ok) {
            const errorBody = await tokenResponse.text();
            console.error("Token exchange failed:", errorBody);
            return NextResponse.redirect(new URL("/login?error=token_exchange_failed", request.url));
        }

        const tokens = await tokenResponse.json();
        // tokens now contains: access_token, id_token, expires_in, token_type, scope

        const response = NextResponse.redirect(new URL("/dashboard", request.url));

        // Clean up the temporary PKCE cookies — their job is done.
        response.cookies.delete("pkce_verifier");
        response.cookies.delete("oauth_state");

        // Store the real session tokens. We'll verify the ID token's signature
        // properly in the next post — for now we store it as-is to get login working end to end.
        response.cookies.set("id_token", tokens.id_token, {
            httpOnly: true,
            secure: process.env.NODE_ENV === "production",
            sameSite: "lax",
            maxAge: tokens.expires_in,
            path: "/",
        });
        response.cookies.set("access_token", tokens.access_token, {
            httpOnly: true,
            secure: process.env.NODE_ENV === "production",
            sameSite: "lax",
            maxAge: tokens.expires_in,
            path: "/",
        });

        return response;
    }

This is steps 5 through 8 from Lecture 2, written out literally: read the authorization code Okta sent back, confirm state matches (rejecting the request otherwise — this is the CSRF check), exchange the code plus the original code_verifier for tokens via a direct POST to Okta's /token endpoint, then store the resulting tokens in httpOnly cookies so client-side JavaScript can never read them directly.

Step 5: A Login Button

Create a simple login page at src/app/login/page.tsx:


    export default function LoginPage() {
        return (
            <div className="flex min-h-screen items-center justify-center bg-slate-50">
                <div className="rounded-lg bg-white p-8 shadow-md text-center">
                    <h1 className="text-2xl font-semibold text-slate-800 mb-2">TaskFlow</h1>
                    <p className="text-slate-500 mb-6">Sign in to continue</p>
                    <a
                        href="/api/auth/login"
                        className="inline-block rounded-md bg-blue-600 px-6 py-2 text-white font-medium hover:bg-blue-700 transition"
                    >
                        Log in with Okta
                    </a>
                </div>
            </div >
        );
    }

Note this is a plain <a> tag, not a client-side router push — we genuinely want a full page navigation to /api/auth/login, since that route immediately issues a server-side redirect to Okta.

Step 6: A Placeholder Dashboard

Just so the flow has somewhere to land, create src/app/dashboard/page.tsx:


    export default function DashboardPage() {
        return (
            <div className="p-8">
                <h1 className="text-2xl font-semibold text-slate-800">Welcome to your dashboard</h1>
                <p className="text-slate-500 mt-2">If you're seeing this, login worked.</p>
            </div>
        );
    }

Try It

Restart your dev server (npm run dev), visit http://localhost:3000/login, and click Log in with Okta. You should be redirected to Okta's hosted login page, log in with the credentials of a user assigned to your app (your own admin account works fine for testing), and land back on /dashboard.

If you get an error instead, two things are worth checking first: that your Sign-in redirect URI in the Okta Application settings exactly matches http://localhost:3000/api/auth/callback (a trailing slash mismatch is a common cause), and that the access policy you added to the default authorization server back in Phase 1 is actually Active.


What's Next

You now have a real, working login flow — no library hiding the mechanics from you. But there are two important gaps: we're storing the ID token without verifying its signature (meaning right now we're trusting Okta blindly rather than proving the token is genuine), and there's no logout or route protection yet.

Phase 2: Basic Authentication — Part 2

Step 1: Actually Verifying the ID Token Right now our callback route stores the ID token without checking anything about it — we're just...