Phase 5: Authorization — Part 2 (Securing API Routes & Fine-Grained Authorization)

Securing Your Own Next.js API Route Handlers

Everything we've protected so far has been a page — a UI a browser navigates to, where cookies are automatically included by the browser. But real apps also expose API endpoints, and those need their own protection, especially once other clients (a mobile app, a third-party integration) might call them directly using a bearer access token instead of a cookie.

The Pattern: Bearer Token Authentication

Suppose TaskFlow needs an API endpoint to fetch a user's tasks: GET /api/tasks. The correct way to secure it is to require the caller to send the access token from Phase 2/3 in an Authorization: Bearer <token> header — the standard OAuth 2.0 way APIs expect access tokens, separate from how a browser page reads its session cookie.

Create src/lib/verifyAccessToken.ts:


    import { jwtVerify, createRemoteJWKSet } from "jose";

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

    export interface AccessTokenClaims {
        sub: string;
        scp?: string[];
        groups?: string[];
        [key: string]: unknown;
    }

    export async function verifyAccessToken(token: string): Promise<AccessTokenClaims> {
        const { payload } = await jwtVerify(token, JWKS, {
            issuer: `${process.env.OKTA_ORG_URL}/oauth2/default`,
            audience: "api://default", // the default audience for Okta's built-in custom authorization server
        });
        return payload as AccessTokenClaims;
    }

Notice this reuses the same jose verification pattern from Phase 2 — signature, issuer, and expiry are all checked the same way. The one meaningful difference is the audience value: ID tokens are audienced to your Client ID (they're "for" your app), while access tokens issued by the default authorization server are audienced to api://default by convention, since access tokens are meant to be presented to APIs, not just consumed by the app that requested them.

Now build the protected endpoint, src/app/api/tasks/route.ts:


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

    export async function GET(request: NextRequest) {
        const authHeader = request.headers.get("Authorization");

        if (!authHeader?.startsWith("Bearer ")) {
            return NextResponse.json({ error: "Missing bearer token" }, { status: 401 });
        }

        const token = authHeader.slice("Bearer ".length);

        let claims;
        try {
            claims = await verifyAccessToken(token);
        } catch {
            return NextResponse.json({ error: "Invalid or expired token" }, { status: 401 });
        }

        // At this point, claims.sub is the verified user ID — safe to use for a database lookup.
        const tasks = [
            { id: 1, title: "Design the login page", ownerId: claims.sub },
            { id: 2, title: "Write Phase 5 notes", ownerId: claims.sub },
        ];

        return NextResponse.json({ tasks });
    }

The key distinction to hold onto: 401 Unauthorized means "I don't know who you are" (missing or invalid token) — that's what this whole block checks. 403 Forbidden means "I know who you are, but you're not allowed to do this" — that's an authorization decision, layered on top, using the groups/role checks we built in Part 1. For example, a DELETE /api/tasks/:id handler would first run this same token verification, then separately check isAdmin(claims) (or a task-ownership check) before allowing the delete, returning 403 if that check fails even though the token itself was perfectly valid.

Calling It From the Client

If a Client Component needs to call this from the browser, it needs the access token available to JavaScript — which conflicts with the httpOnly cookie approach we deliberately chose in Phase 2 for security. The clean solution is to not expose the raw token to the browser at all; instead, proxy the call through a Server Action or another Route Handler that reads the httpOnly cookie server-side and forwards it internally. That keeps the access token out of browser-accessible JavaScript entirely, preserving the same security property we established back in Phase 2.

Beyond Roles and Groups: Fine-Grained Authorization

Roles and groups (Part 1) answer questions like "is this user an admin?" — a fixed, small number of categories. But real apps often need something more precise: "can this specific user edit this specific task that another specific user created and shared with them?" Modeling that with groups alone gets unwieldy fast — you'd end up creating a new group for every single resource, which doesn't scale.

This is exactly the problem Relationship-Based Access Control (ReBAC) solves, and Okta's offering here is built on OpenFGA — an open-source authorization engine (originally built by Auth0/Okta, since donated to the Cloud Native Computing Foundation) inspired by the same model Google uses internally (called Zanzibar). One naming note worth flagging clearly, since it trips people up: this product is currently branded Auth0 FGA (Fine-Grained Authorization) rather than "Okta FGA" — same underlying technology and team, but if you go looking for it, the dashboard and docs live under the Auth0 FGA name.

How It's Different From What We've Built

Instead of encoding permissions as claims inside a token, FGA moves authorization out of the token entirely, into a separate, centralized service you query at request time. You define:

  • Object types — e.g., task, folder, user.
  • Relations — e.g., a user can be an owner, editor, or viewer of a task.
  • Relationship tuples — actual facts, like "user:priya is owner of task:42" or "task:42's folder is folder:7, and user:arjun is a viewer of folder:7" (note this last example shows relationships can be inherited — a viewer of a folder becomes a viewer of everything inside it, without you writing that logic yourself).

At request time, instead of reading a groups array from a token, your API asks FGA a direct question: "can user:arjun view task:42?" — and FGA evaluates the full relationship graph (including inherited folder permissions) and returns a yes/no answer, fast, even across relationships with billions of tuples.

When You Actually Need This

To be clear about scope: TaskFlow, as we've built it, doesn't need FGA yet — the groups-based admin check from Part 1 comfortably covers "is this user an admin." You'd reach for FGA once TaskFlow grows features like sharing individual tasks with specific other users, nested folder permissions, or per-resource collaboration — the moment "which group is this user in" stops being able to express the permission you need. This course won't build a full FGA integration into TaskFlow, since it's a genuinely separate service with its own SDKs and modeling process, but knowing it exists — and specifically that it's the tool for relationship-shaped permissions rather than role-shaped ones — is the important takeaway for this lecture.


Phase 5 Is Complete

TaskFlow now enforces authorization at every layer: pages check verified claims server-side before rendering, API Route Handlers require and verify bearer access tokens independently of page cookies, and you know exactly when to reach past roles and groups into a relationship-based model like FGA.

In phase 6 we move to the backend: using the Okta Node Management SDK to manage users programmatically, and customizing Okta's own behavior with Inline and Event Hooks.

No comments:

Post a Comment

Phase 5: Authorization — Part 2 (Securing API Routes & Fine-Grained Authorization)

Securing Your Own Next.js API Route Handlers Everything we've protected so far has been a page — a UI a browser navigates to, where co...