Phase 5: Authorization — Part 1 (Custom Claims & Role-Based Access Control)

Everything so far has answered "who is this user?" This phase answers the other half: "what is this specific user allowed to do inside TaskFlow?" That's authorization, and it's a genuinely separate concern from authentication — a user can be perfectly, verifiably logged in and still be forbidden from doing certain things.

Custom Claims: Putting Your Own Data Inside the Token

Step 1: Add a Custom User Attribute

  1. In the Admin Console, go to Directory → Profile Editor, select User (default), click Add Attribute.
  2. Fill in:
    • Data type: string
    • Display name: role
    • Variable name: role
    • Enum: check Define enumerated list of values, and add two entries under Attribute members: member / member and admin / admin.
    • Restriction: leave Value must be unique for each user unchecked.
    • Attribute required: leave unchecked.
    • Default value: optional — set to member if you want new users to default to it.
    • User permission: set to Read Only, so users can see their own role but not change it themselves.
  3. Click Save.

Step 2: Set the Value on a Real Test User

Go to Directory → People, open the specific user you intend to test with, go to their Profile tab, click Edit, and set role to admin. Save. Do this for at least one test account so there's real data for the claim to read.

Step 3: Turn the Attribute Into a Token Claim

Go to Security → API → Authorization Servers → default → Claims, click Add Claim.

A single claim entry in Okta can only target one token type at a time — Access Token or ID Token, never both together. Since TaskFlow needs this data in both places (the /admin page reads the ID token, while any API Route Handler reads the Access token), you create the same claim twice, once per token type.

Also worth knowing upfront: certain plain, common words — including role — are reserved and can't be used as a claim name on the ID Token. Using a slightly more specific name avoids this entirely, so this course uses userRole as the claim name throughout (the underlying Okta profile attribute is still called role — only the name of the claim inside the token changes).

Claim entry 1 — Access Token:

  • Name: userRole
  • Include in token type: Access Token, Always
  • Value type: Expression
  • Value: user.profile.role
  • Include in: Any scope
  • Click Create.

Claim entry 2 — ID Token:

  • Name: userRole
  • Include in token type: ID Token, Always (not the default Userinfo/id_token request, which only includes the claim when specifically requested — Always guarantees it's present every time)
  • Value type: Expression
  • Value: user.profile.role
  • Include in: Any scope
  • Click Create.

You should now see two rows named userRole in the Claims table — one with Type access, one with Type id. That's correct.

Groups: The More Scalable Alternative

Okta's native, purpose-built mechanism for this same problem — rather than a single string attribute — is Groups, read through a dedicated groups claim type, which isn't a reserved name and works the same way structurally.

Step 1: Create the Group

Go to Directory → Groups, click Add group, name it TaskFlow-Admins, and add your test user(s) to it (from the group's People tab, click Assign people).

Step 2: Create the Claim — Again, Twice

Back in Security → API → Authorization Servers → default → Claims → Add Claim:

Claim entry 1 — Access Token:

  • Name: groups
  • Include in token type: Access Token, Always
  • Value type: Groups
  • Filter: Starts withTaskFlow-
  • Include in: Any scope
  • Click Create.

Claim entry 2 — ID Token:

  • Name: groups
  • Include in token type: ID Token, Always
  • Value type: Groups
  • Filter: Starts withTaskFlow-
  • Include in: Any scope
  • Click Create.

The Starts with TaskFlow- filter is Okta's recommended pattern — it prevents irrelevant internal Okta groups (like admin-console-only groups) from leaking into your app's tokens. Avoid a broad match like .*, which would expose every group in your org.

With both entries created, every token — ID and Access alike — will include a groups array like ["TaskFlow-Admins"] for users in that group, and an empty array for everyone else.

Enforcing It in Next.js

Update src/lib/verifyToken.ts:


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

Create src/app/admin/page.tsx:


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

    export default async function AdminPage() {
        const cookieStore = await cookies();
        const idToken = cookieStore.get("id_token")?.value;
        if (!idToken) redirect("/login");

        const claims = await verifyIdToken(idToken);
        const isAdmin = claims.groups?.includes("TaskFlow-Admins");

        if (!isAdmin) {
            redirect("/dashboard");
        }

        return (
            <div className="p-8">
                <h1 className="text-2xl font-semibold text-slate-800">Admin Panel</h1>
                <p className="text-slate-500 mt-2">Only TaskFlow-Admins can see this page.</p>
            </div>
        );
    }

This is deliberately a server-side check using verified token claims, not something decided by hiding a link in the UI — a user without the TaskFlow-Admins group typing /admin directly into the URL bar is still redirected away, before any admin content renders.

A Reusable Helper

Create src/lib/authz.ts:


    import type { OktaIdTokenClaims } from "@/lib/verifyToken";

    export function hasGroup(claims: OktaIdTokenClaims, groupName: string): boolean {
        return claims.groups?.includes(groupName) ?? false;
    }

    export function isAdmin(claims: OktaIdTokenClaims): boolean {
        return hasGroup(claims, "TaskFlow-Admins");
    }

Now the admin page check simplifies to if (!isAdmin(claims)) redirect("/dashboard");.

Protecting the Route in Middleware

Update src/middleware.ts's matcher from Phase 2/3 to also cover this new page:


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

This ensures unauthenticated users are bounced by Middleware before even reaching the page — layered underneath the claims check inside the page itself, the same defense-in-depth pattern established back in Phase 2.

Verifying It Works

A couple of things matter here that are easy to overlook:

  • Claims only apply to newly issued tokens. If you change anything under the Authorization Server's Claims tab, an already-logged-in session's token was minted before that change existed and won't reflect it. Always log out completely (not just reload the page) and log back in before testing a claims change.
  • To inspect exactly what's inside a token, add a temporary debug line in the Server Component:

    console.log("ID token claims:", claims);

Since this code runs on the server, the output appears in your terminal (wherever npm run dev is running) — not the browser's DevTools console. This is the fastest way to confirm exactly what a token contains rather than guessing why a redirect is happening.


Where TaskFlow Stands

Tokens now carry real, meaningful authorization data — a userRole attribute and a groups array — available in both the ID token and the Access token. TaskFlow enforces access to an admin-only page based on these verified claims, checked server-side, before any protected content ever renders.

No comments:

Post a Comment

Phase 5: Authorization — Part 1 (Custom Claims & Role-Based Access Control)

Everything so far has answered "who is this user?" This phase answers the other half: "what is this specific user allowed to ...