RoadsideCoderNotes

React Authentication (Full Notes)

Complete written notes for the React authentication tutorial: authentication vs. authorization, sessions vs. tokens, JWTs, OAuth 2.0 and OIDC, PKCE, and a full app-native build with Next.js: login, signup, MFA, social login, roles, protected routes, and a protected backend API.

Full written notes to follow along with the video, or come back to as a reference later. The first half covers the fundamentals: authentication vs. authorization, sessions vs. tokens, JWTs, OAuth 2.0/OIDC, and PKCE, the same concepts behind every "add login to your app" tutorial, framework-agnostic. The second half turns those fundamentals into real code: a full login/signup/roles/protected-route build using app-native authentication with Next.js.

Note

This build uses app-native authentication with Next.js (@asgardeo/nextjs), not the hosted-redirect flow. WSO2 also ships a React SDK (@asgardeo/react) built around SignInButton, which redirects out to a hosted login page instead, genuinely the easier, more secure default for most apps, since there's no login form of your own to secure. This guide deliberately builds the in-app version instead, so the whole flow stays visually inside the app. Where the two approaches differ meaningfully, both are called out.

Authentication vs. authorization

Two different questions, easy to blur together:

  • Authentication answers "who are you." Login and signup live here.
  • Authorization answers "what are you allowed to do." Roles and protected routes live here.

Running example: a blog app. Logging in is authentication. Whether you can see /dashboard versus /admin is authorization, and both checks can reuse the exact same login step, since what differs is only which gate the resulting ticket has to clear.

1 · authenticate2 · authorize: /dashboard3 · authorize: /adminguestloginticketrole: usersigned in?✓ renderedrole = admin?✗ blockedredirected to /loginswap the ticket to role: admin and nothing else changes — the same gate now answers ✓ instead of ✗

Same login step for everyone — what differs is which gate the role claim on the ticket clears.

Why this split matters beyond a two-word definitionGo deeper

Keeping authentication and authorization as separate checks, rather than one blended "is this allowed" function, is what makes a system auditable. When something goes wrong you want to ask two independent questions: was the identity verification sound, and separately, was the permission decision correct. Systems that blend the two tend to leak privilege in subtle ways, because a bug in "who are you" silently becomes a bug in "what can you do."

Beyond simple roles

A single role string is the beginner version of authorization. Production systems tend to move through three stages as they grow:

  • RBAC (role-based), what this build uses: a fixed set of roles (user, admin, editor), each with implied permissions. Simple, but coarse; you often end up with roles like admin_billing_only that exist purely to route around RBAC's rigidity.
  • ABAC (attribute-based): permissions computed from attributes at request time, not a fixed role. A few concrete rules: "finance department can approve invoices under $10k," "a user can edit a document only if they're on the same team as its owner," "support tickets can only be closed during business hours," "export access requires the pro subscription tier." Each rule mixes attributes about the user, the resource, and sometimes the request itself (time, IP, device), more flexible than a role check, harder to reason about and test exhaustively since the rule set can grow combinatorially.
  • ReBAC (relationship-based): permission derives from a relationship graph: "can edit because they're a member of the team that owns this document." This is what Google Docs-style sharing models, and what tools like Zanzibar (Google) or OpenFGA/SpiceDB (open source) implement. Worth knowing as the natural next step once roles stop scaling.
RBACuser → role: editorrole: editor → can edit✓ grantedFixed set of roles, eachwith implied permissions.Simple, but coarse.ABACdept = finance ✓owner = same team ✓time = business hrs ✓✓ grantedComputed from attributesat request time. Flexible,harder to test exhaustively.ReBACusermember ofdesignownsdocument✓ grantedPermission derives from arelationship graph. GoogleDocs-style sharing.

Same question, three ways to answer it: a fixed role, attributes checked at request time, or a relationship graph.

A classic failure mode: the confused deputy problem

A service with legitimate high privilege gets tricked into using that privilege on behalf of a lower-privileged caller who couldn't have done the action directly. "The backend is authenticated as a trusted service" is not the same guarantee as "the end user is authorized for this specific action." Authorization checks have to travel with the request, not just live at the perimeter.

AttackerOrder Service(trusted deputy)Payments Service1. POST /refund {orderId: 999} — not their order2. POST /refund using service_api_keytrusts the key→ refunds order 999$ refunded tothe attackerthe fix: Order Service must check that this caller owns order 999 using thecaller's own token, before it ever spends its own trusted service_api_key

The order service's credential is real and trusted — the missing piece is checking that THIS caller owns order 999, before spending it.

Sessions vs. tokens

Two ways a server remembers you're logged in on the next request:

  • Session-based: the server creates a session record in its own store and hands the browser a cookie referencing it. Every later request means a lookup.
  • Token-based (JWT): the server signs a token and hands it to the client. Every later request means verifying a signature; no store, no lookup.
Session-basedBrowserServerPOST /loginsession storesess_9f2 → user_42Set-Cookie: sid=sess_9f2 (httpOnly)later: Cookie sent → lookup → 200 OKevery request costs a store lookupToken-based (JWT)BrowserServerPOST /loginsigns a JWT locallyno server memory used200 OK { token: eyJhbGciOi… }later: Bearer token → verify signature → 200 OKstateless — but revoking early means a denylist

Same two moves, different middle step: a store lookup on the left, a signature check on the right.

Trade-offs, stated plainly: sessions are easy to revoke (delete the store row) but need server-side state. JWTs scale better (no store to hit on every request) but are hard to revoke early, and have to be stored carefully on the client.

There's no famous third-party visualizer for sessions, because the mechanism is just plain HTTP. The best way to actually see one is your own browser's DevTools: Application tab, look at the real Set-Cookie header and the cookie itself sitting in storage, including whether httpOnly is set.

Refresh tokens and rotation

Two tokens get issued at login, not one:

  • The access token is the one sent with every API request, short-lived on purpose, usually just minutes. If it leaks, the damage window is tiny.
  • The refresh token does one thing only: trade itself in for a new access token once the old one expires, without making the user log in again. It's long-lived (days or weeks) and never sent to a regular API, only to the "give me a new access token" endpoint.

Think of it like a hotel key card that expires every hour, plus a separate, longer-lived voucher at the front desk that gets you a new key card without checking in again. Losing the key card for an hour is a minor problem; losing the voucher is the actual risk, which is exactly why refresh tokens get extra protection.

The basic flow, before rotation: access token expires, app sends the refresh token to the server, server checks it's still valid, server hands back a fresh access token. That's it. This alone is "silently refresh before the token expires," and it's enough for a lot of apps.

What rotation adds, and why: the gap in the basic flow is that the same refresh token gets reused every time, for weeks. If it's ever stolen (leaked from storage, intercepted, whatever), the thief can keep minting new access tokens indefinitely, and there's no way to tell their requests apart from the real user's.

Rotation closes that gap: every refresh doesn't just mint a new access token, it also mints a new refresh token and permanently kills the old one. So each refresh token is single-use, like a one-time password instead of a reusable key. Follow what that means for an attacker: if they steal a refresh token and use it, the legitimate app's next refresh attempt fails, because its refresh token was already invalidated by the attacker's use. That failure is the tell: the server sees an already-used token being replayed and can conclude something's wrong, not just "expired."

Clientholds R1Server1. POST /token/refresh {refresh_token: R1}validates R1, mints A2 + R2marks R1 permanently used2. { access_token: A2, refresh_token: R2 }discards R1, stores R2if a copy of R1 is replayed later → server sees an already-used token→ revokes the whole family → forced re-login

A refresh token is one-time-use — reusing an already-rotated one is treated as a theft signal, not a retry.

Why this matters in practice:

  • Sliding sessions: many apps use refresh rotation to implement "stay logged in while active, log out after N days idle" without a traditional server-side session store; each rotation extends the window.
  • Reuse detection turns theft into a tripwire: since a used refresh token should never be seen again, replaying one is a near-certain signal of a stolen token, not a fluke. The server can respond by revoking the whole chain of tokens descended from it, forcing everyone (attacker included) to log in again.
  • This is exactly the mechanism a serious identity platform implements for you, one of the more legitimate "why not build this yourself" arguments.

JWTs, decoded

A JWT is three base64url segments joined by dots: header, payload, signature.

HEADER{ "alg": "HS256","typ": "JWT" }PAYLOADsub: "user_42"role: "admin"exp: 1799999999SIGNATUREHMACSHA256(b64(header)+"."+b64(payload),secret)↑ this is the claim RBAC readsmismatch → 401eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzQyIiwicm9sZSI6ImFkbWluIn0.4f8a2c…header . payload . signature — three base64url segments, joined by dots

Signed, not encrypted: anyone can decode the payload. The signature only proves it wasn't tampered with.

The payload is readable by anyone holding the token, a JWT is signed, not encrypted, so the signature exists to prove the payload hasn't been edited, not to hide its contents. Never put secrets in a JWT payload.

Decoding one is nothing exotic, just two base64 splits and a JSON.parse, no library required, which is exactly why a JWT should never be treated as a secure container:

// three lines, no library: the same operation jwt.io does visually
const payload = token.split(".")[1];
const claims = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
console.log(claims); // { sub: "user_42", role: "admin", exp: 1799999999 }
Note

Best live-demo moment for this concept: log in on any app that issues JWTs, copy the real token, and paste it into jwt.io, decoded and color-coded live. Edit the role claim in the payload panel and watch signature verification flip to invalid; that's what "signed, not encrypted" actually looks like.

Signing algorithms, JWKS, and the alg:none attackGo deeper

The example above signs with HS256 (a shared secret), fine for a single backend. Once multiple services need to verify tokens, algorithm choice matters more:

  • HS256 (symmetric): one shared secret. Every verifying service must hold the same secret as the issuer; one leaked secret breaks trust everywhere.
  • RS256 (asymmetric): the issuer keeps a private key and publishes a public key (via a JWKS endpoint) that services fetch to verify signatures. Verifiers can check signatures but can't forge new tokens. This is what most identity platforms use by default.
HS256 (symmetric)Auth Serversigns with Ssecret SService A (holds S)Service B (holds S)every verifier must hold S — one leakbreaks trust everywhereRS256 (asymmetric)Auth Serverprivate keyJWKS: public keyService A (fetches)Service B (fetches)verifiers can check signatures but can'tforge new tokens

Symmetric signing means every verifier could also forge a token. Asymmetric signing splits signing from verifying.

A classic attack this history explains

JWT libraries once trusted the alg field inside the token itself to decide how to verify it. An attacker could take a legitimate RS256 token, change alg to HS256, and re-sign it using the issuer's public key as an HMAC secret, since public keys are, well, public, a naive verifier would accept the forgery. Modern libraries fix this by making the caller specify the expected algorithm explicitly, rather than trusting the token to declare it. This is a real CVE-class bug, not a hypothetical.

Other claims worth knowing exist

  • iss (issuer) and aud (audience): in multi-tenant or multi-service systems, verifying these prevents a token issued for one app/tenant being replayed against another.
  • Clock skew: exp/nbf checks need some tolerance (often 30-60s), since server clocks aren't perfectly synced. A common source of intermittent "valid token rejected" bugs.
  • JWKS rotation: asymmetric setups publish a set of public keys, keyed by kid in the token header, so the private key can rotate without breaking recently-issued tokens.

OAuth 2.0 and OIDC, in plain English

Two different things, easy to blur into one "the Google login thing." The clearest way to keep them apart is a valet key. Handing a valet a special key that only starts the car and can't open the glovebox is OAuth: proof that this specific request is allowed to do this specific thing, nothing more. It says nothing about who the valet is. Pairing that key with an ID card that proves whose car it is, that's OIDC, an identity layer added on top.

OAuth 2.0 aloneanswers: “is this request allowed?”🔑 valet keystarts the car — nothing elseaccess_token: call this API, these scopesNo idea whose car it is.OIDC on top of OAuthadds: “who is this?”🔑 valet key + 🪪 ID cardstarts the car, proves whose it isaccess_token + id_token: sub, email, name…plus a /userinfo endpointThis is what “Sign in with Google” runs.

OAuth alone answers "is this allowed" — like a valet key that starts the car but can't open the glovebox. OIDC adds "who is this" on top.

Same split, in the terms you'll actually see in code and docs:

  • OAuth 2.0 answers "is this request allowed to do X." A calendar app asking "can I read this person's Google Calendar events" is a pure OAuth question: it gets back an access_token scoped to calendar.readonly, and that's all it ever needed to know. OAuth was never designed to answer "who is this person," and plenty of real OAuth usage (API-to-API calls, machine-to-machine access) never involves a human "identity" at all.
  • OpenID Connect (OIDC) answers "who is this person," layered on top of the same mechanics. "Sign in with Google" is this: the app doesn't just want permission to call an API, it wants to know whose account just logged in, so OIDC adds an id_token (a JWT carrying who-you-are claims like email and name) and a standard /userinfo endpoint.

Not a spec deep-dive, just enough that "Sign in with Google" and "redirect to a login page, come back with a token" make sense. This is the authorization code flow, the standard shape behind almost every third-party login button, and it's shared by both OAuth and OIDC:

Browserwhat the user seesYour Appyour serverIdentity Providere.g. WSO2, Google1. user clicks “Sign in”redirect → /authorize?client_id&redirect_uri2. app sends them to log inredirect → /authorize?client_id&redirect_uri3. user logs in + approvesGET /authorize — user logs in + consents4. provider hands back a one-time code302 → redirect_uri?code=abc1235. browser passes the code to the appGET /callback?code=abc1236. app trades the code for tokens, privatelyPOST /token (server-to-server, code + client_secret)7. provider sends back the real tokensaccess_token [OAuth]+ id_token [OIDC only]8. app signs the user insession/cookie setSame flow either way. A calendar app asking to read events only ever gets access_token — pure OAuth, no id_token needed.“Sign in with Google” needs id_token too — that's the OIDC part, a JWT with the claims right there: sub, email, name.

The code in step 4 is single-use and short-lived — not the credential, just a claim ticket exchanged for tokens server-to-server.

The code the browser receives in step 4 is single-use and short-lived. It isn't the credential, it's a claim ticket the app exchanges, privately and server-to-server, for the tokens that actually carry identity.

Note

Step through a real exchange instead of just reading the diagram: the OAuth 2.0 Playground and Google's own OAuth 2.0 Playground let you watch the actual redirect URL, the code parameter, and the token response happen against a real provider.

PKCE (Proof Key for Code Exchange, pronounced "pixy") is an extension to the authorization code flow above, same shape, same steps, with one addition that closes a specific gap. The flow as drawn assumes "Your App" is a server that can keep a client_secret private when exchanging the code for tokens. A single-page app running entirely in-browser has no such server, so a plain code exchange is unsafe as drawn: anything shipped to the browser can be read out of it. PKCE removes the need for that secret entirely, replacing it with a value only the same client that started the flow could know:

  1. The client generates a random code_verifier locally, and sends only its hashed form, the code_challenge, when starting the /authorize request.
  2. The identity provider stores that challenge against the code it issues.
  3. When the client exchanges the code for tokens, it must present the original code_verifier. The provider checks it hashes to the stored challenge before issuing tokens.
Client (SPA)picks a secret only it knowsIdentity Provider1. sends only a one-way fingerprint of the secretGET /authorize?…&code_challenge=hash(code_verifier)stores the fingerprint alongside the code it issues2. provider hands back a one-time code302 → redirect_uri?code=abc123 — this is the part an attacker could intercept3. proves it's the same client, using the original secretPOST /token {code, code_verifier}IdP re-hashes it, checks it matches the stored fingerprintLike a locker code only you set: stealing the claim ticket (the code) is uselesswithout the number you picked yourself — and that number never traveled anywherean interceptor could read.

An intercepted authorization code is useless without the code_verifier — which never travels until the final, direct exchange.

Two more sharp edges: the state parameter, and the deprecated implicit flowGo deeper
  • The state parameter (omitted from the main diagram): a random value the app generates before redirecting out, and checks matches on return. It stops an attacker tricking a victim into completing someone else's login flow, CSRF protection specific to the redirect.
  • The implicit flow is deprecated. Older tutorials return tokens directly in the redirect URL fragment, skipping code exchange entirely. No longer recommended; tokens end up in browser history and server logs. Authorization code + PKCE is the current standard for browser-based apps.

Why "just roll your own auth" gets painful

Specific reasons, not a scare story:

  • Password hashing has to be done right: bcrypt or argon2, properly salted.
  • Refresh logic is easy to get subtly wrong.
  • Revoking sessions at scale is real infrastructure work.
  • MFA, social login, and passkeys each take real implementation time.

Every concept above is about to become real code, running on a platform that's already solved those problems.

Preparing for frontend interviews?

My Frontend Interview Preparation course is the only resource you'll need: in-depth JavaScript, React, system design, machine coding, and more, all in one place.

Check out the course

Building it with WSO2 Identity Platform

WSO2 Identity Platform is available both as a downloadable, self-hosted product and as a fully managed SaaS. This build uses the SaaS version throughout, since it needs no local software setup at all: wso2.com/identity-platform/developer, free tier, no card required. It handles login, signup, MFA (OTP, magic links, passkeys), social login, self-service, and RBAC, with drop-in SDKs for React, Next.js, Vue, and Nuxt.

Note

The product was renamed from Asgardeo to WSO2 Identity Platform, naming only, no functional change. The npm package, signup domain, and component/hook names still use the old name (@asgardeo/nextjs, asgardeo.io, AsgardeoProvider, useAsgardeo), which is why they show up throughout the code below.

Prerequisites: Node.js v20+ and npm, a text editor, and a WSO2 Identity Platform account. A Google account too, if following the social login section. Budget about 60 minutes end to end.

Configure an application

Sign up at wso2.com/identity-platform/developer (free tier, no card required) and create an organization. New accounts land on WSO2's Setup Guide first, a guided walkthrough for registering an application and enabling MFA, branding, and similar settings. Use it: pick Next.js as the framework when the guide asks, and it handles the application registration (including exposing app-native auth) as part of the flow.

If the Setup Guide isn't available (an existing account, or it's been dismissed), here's the manual path:

  1. Applications → New Application → pick the Next.js quick-start template. This is what exposes the app-native authentication toggle used throughout this build.
  2. Complete the wizard: name it (e.g. react-auth-demo) and set an authorized redirect URL, http://localhost:3000 for local dev, matching the Next.js dev server default. Has to match exactly where the app is actually served.
  3. Open the Protocol tab → note down the Client ID and Client Secret. (Unlike a Single Page Application registration, which never gets a secret since a browser-only app can't hold one safely, a Next.js app runs partly on a server, so it's registered as a confidential client that does get one.)
  4. Open the Advanced tab → check "Enable app-native authentication API"Update. This is the one setting that turns on the embedded, in-app login/signup forms this whole build uses.

Worth a quick tour of the rest of the console before writing any code:

  • Applications: where the app lives: protocol settings, allowed redirect URLs, client ID/secret, the app-native toggle.
  • User Management: the user list, and where Groups and Roles get created and assigned (reused in the roles section below).
  • Flows: a no-code visual canvas for the self-registration and password-recovery journeys. Self-registration ships disabled by default; open the Self Registration flow here and toggle it on via the switch in the top-right of the builder canvas before signup will work.
  • Branding: logo, colors, and copy, though app-native forms render with the SDK's own default styling rather than the hosted-page branding.

Create at least one test user here too (User Management → Users → Add User), useful for testing sign-in immediately, though once signup is wired up below, creating an account through the app itself works just as well.

Create the Next.js app

npm create next-app@latest react-auth-demo -- --yes --typescript --tailwind --eslint --app --no-src-dir
cd react-auth-demo

WSO2's App-Native Complete Guide scaffolds a plain, unstyled Next.js app and adds no styling framework. This build uses Tailwind (the create-next-app default) plus shadcn/ui components for the actual UI, deliberately no custom CSS, so nothing here fights the styling <SignIn />/<SignUp /> already ship with. Not something WSO2's guide specifies either way.

Set up shadcn/ui now, then add the handful of components used across the app:

npx shadcn@latest init -d
npx shadcn@latest add button card badge separator navigation-menu

Confirm the dev server is running, then visit http://localhost:3000, the default Next.js starter page should load.

Configure the SDK

Install @asgardeo/nextjs, the Next.js-specific SDK, distinct from @asgardeo/react used in the redirect-flow build:

npm install @asgardeo/nextjs

Create .env.local with the values from the app's Protocol tab:

# .env.local
NEXT_PUBLIC_ASGARDEO_BASE_URL="https://api.asgardeo.io/t/your_org_name"
NEXT_PUBLIC_ASGARDEO_CLIENT_ID="your_client_id"
ASGARDEO_CLIENT_SECRET="your_client_secret"
NEXT_PUBLIC_ASGARDEO_SCOPES="openid profile"
NEXT_PUBLIC_ASGARDEO_SIGN_IN_URL="/sign-in"
ASGARDEO_SECRET="a-long-random-string"

Two things about these variables worth knowing before moving on:

  • ASGARDEO_CLIENT_SECRET has no NEXT_PUBLIC_ prefix, deliberately. Next.js only bundles NEXT_PUBLIC_-prefixed env vars into client-side JavaScript; leaving the prefix off keeps the secret server-side only, read by the Node process, never shipped to the browser. This is a real security boundary, not just a naming convention; mixing this up would leak the secret to anyone opening DevTools.
  • ASGARDEO_SECRET is a separate variable from ASGARDEO_CLIENT_SECRET, undocumented in WSO2's own guides. It signs a temporary session cookie used only during sign-in; sign-up doesn't touch this code path, so a deploy can pass every sign-up test and still break on sign-in. Locally, the SDK silently falls back to an insecure default and just logs a warning; in production it throws instead, surfacing as a generic "error during sign-in" with no clue what's missing. Generate a real value with openssl rand -base64 32 and set it wherever the app is deployed (e.g. Vercel's environment variables), not just in .env.local.

Next.js needs two integration points, not one: proxy.ts at the project root for route-level session handling, and a provider wrapping the app for component-level access. (@asgardeo/nextjs's own examples still call this file middleware.ts, same shape, proxy.ts is just the current name.)

// proxy.ts (project root)
import {
  asgardeoMiddleware,
  createRouteMatcher,
} from "@asgardeo/nextjs/middleware";
 
const isProtectedRoute = createRouteMatcher([
  "/dashboard*",
  "/profile*",
  "/admin*",
]);
 
export default asgardeoMiddleware(async (asgardeo, req) => {
  if (isProtectedRoute(req)) {
    return await asgardeo.protectRoute();
  }
});
 
export const config = {
  matcher: [
    "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
    "/(api|trpc)(.*)",
  ],
};

The return before asgardeo.protectRoute() matters: it resolves to a redirect NextResponse when signed out, or undefined when signed in, and that value has to be returned from the handler for the redirect to actually apply.

// app/layout.tsx
import { AsgardeoProvider } from "@asgardeo/nextjs/server";
import "./globals.css";
 
// AsgardeoProvider reads the session from request cookies on every
// render, so every page under it needs a live request context, none of
// them can be statically prerendered at build time.
export const dynamic = "force-dynamic";
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {/* afterSignInUrl applies to both sign-in and sign-up completion — sends
            users to /dashboard instead of the public home page. */}
        <AsgardeoProvider afterSignInUrl="/dashboard">
          {children}
        </AsgardeoProvider>
      </body>
    </html>
  );
}

afterSignInUrl resolves to a full URL (http://localhost:3000/dashboard) and gets sent to WSO2 as the OAuth redirect_uri. That exact URL needs its own entry in the console's Authorized redirect URLs (Protocol tab), alongside the bare origin from "Configure an application" above. Skipping this fails with invalid_callback / callback.not.match, confirmed live: the error surfaces generically inside the embedded sign-in flow rather than as an obvious redirect mismatch, so it's easy to lose time on. Add both the local (http://localhost:3000/dashboard) and deployed (https://your-app.vercel.app/dashboard) versions if testing both.

Note

export const dynamic = "force-dynamic" is required here: Next.js tries to statically prerender pages by default, but AsgardeoProvider needs live request headers that don't exist at build time. Since this is an auth-gated app where every page needs a live session check anyway, forcing dynamic rendering app-wide is the right call.

AsgardeoProvider here is the server provider (@asgardeo/nextjs/server): it wraps the client-side provider internally and reads clientId/baseUrl/clientSecret from the env vars automatically, no props required. Every component underneath can use useAsgardeo() (client components) or the asgardeo() server helper (server components/actions) to access sign-in state.

The home page itself needs no auth check at all, it's public, reachable by anyone, signed in or not:

// app/page.tsx
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
 
export default function Home() {
  return (
    <div className="flex flex-col items-center gap-4 pt-4 text-center">
      <Badge variant="outline">WSO2 Identity Platform</Badge>
      <h1 className="text-4xl font-semibold tracking-tight">React Auth Demo</h1>
      <p className="max-w-prose text-muted-foreground">
        Public home page. Sign in above to reach <code>/dashboard</code>; sign
        in as a user with the <code>admin</code> role (set up in the WSO2
        console under User Management → Roles) to also reach <code>/admin</code>
        .
      </p>
      <p className="mt-1">
        No account yet? <Link href="/sign-up">Sign up</Link>
      </p>
    </div>
  );
}

Add login and logout

Concept in play: authentication, "who are you." App-native auth still runs the OAuth 2.0 authorization code flow underneath, just without the visible redirect; see the hosted-redirect alternative below to actually see that flow happen. This build never shows PKCE directly in code either, for the same reason: PKCE protects the code-exchange step of a redirect flow, and app-native structurally has no redirect or exposed code to protect; the credential exchange happens through WSO2's app-native APIs directly instead. OIDC, on the other hand, is everywhere here even though it's invisible: user.roles, the profile claims on <UserProfile />, the userinfo call in "Securing your backend API," all of that is the id_token/userinfo layer OIDC adds on top of bare OAuth.

<SignIn /> renders WSO2's embedded, app-native login form inside the app, no redirect to a hosted page. This is the entire point of app-native authentication: the credentials entered are exchanged for a token via WSO2's app-native APIs directly, without the page ever leaving this route.

// app/sign-in/page.tsx
"use client";
 
import { SignIn } from "@asgardeo/nextjs";
 
export default function SignInPage() {
  return <SignIn />;
}

NEXT_PUBLIC_ASGARDEO_SIGN_IN_URL="/sign-in" in .env.local tells the SDK/proxy where to send signed-out users who hit a protected route.

Alternative: hosted-redirect SignInButton, instead of app-nativeGo deeper

The plain React build (and WSO2's own default recommendation for most apps) uses SignInButton/SignOutButton instead: redirects out to WSO2's hosted login page and back, the eight-step authorization code flow from the OAuth diagram earlier. That's genuinely the easier, more secure default: no login form of your own to secure, and it works on any application type, no "Enable app-native authentication API" checkbox needed:

import { SignedIn, SignedOut, SignInButton, SignOutButton } from "@asgardeo/nextjs";
 
<SignedOut>
  <SignInButton />
</SignedOut>
<SignedIn>
  <SignOutButton />
</SignedIn>

This build deliberately uses the in-app, app-native form instead, so the whole flow stays visually inside the app rather than bouncing to WSO2's own domain.

Alternative: hook-based sign-in, for custom UIGo deeper

For a component that needs more control than the prebuilt form allows, useAsgardeo() exposes the underlying pieces directly:

"use client";
import { useAsgardeo } from "@asgardeo/nextjs";
 
function CustomSignIn() {
  const { isSignedIn, signIn, signOut } = useAsgardeo();
  return isSignedIn ? (
    <button onClick={() => signOut()}>Sign Out</button>
  ) : (
    <button onClick={() => signIn()}>Sign In</button>
  );
}

Calling signIn() with no payload falls back to the redirect-based flow rather than app-native. This shortcut doesn't reimplement the app-native form's multi-step API calls, it just triggers whichever flow the SDK defaults to without one. For a fully custom app-native UI (custom-styled fields instead of the SDK's own form styling), WSO2's guide points to the lower-level executeEmbeddedSignInFlow function from @asgardeo/browser that <SignIn /> itself delegates to, a deeper integration than most apps need, since <SignIn /> already renders every enabled factor without custom styling work.

At this point you'll need a real test user, create one via User Management → Users → Add User in the console, or once signup is wired up below, sign up through the app itself.

Customize the form copy

<SignIn />'s field is labeled "Username" by default, even though this build's login identifier is email, worth relabeling so it matches what the form actually expects. Unlike signup's field text (editable directly in the Flow Builder), the login flow's copy isn't console-editable, so the SDK exposes it as a preferences prop on AsgardeoProvider instead:

// app/layout.tsx
<AsgardeoProvider
  afterSignInUrl="/dashboard"
  preferences={{
    i18n: {
      bundles: {
        "en-US": {
          translations: {
            "elements.fields.username.label": "Email (Username)",
            "elements.fields.username.placeholder": "Enter your email",
          },
        },
      },
    },
  }}
>
  {children}
</AsgardeoProvider>

The same mechanism overrides any other text the embedded components render. One thing to know while developing: the SDK reads these preferences once when the Next.js server starts, restart the dev server after changing them, or the old label sticks around.

Manage passwords

Concept in play: why "roll your own auth" gets painful, "password hashing has to be done right, bcrypt or argon2, properly salted" covers storage, but recovery, complexity rules, reuse history, and expiry are each their own surface of easy-to-get-wrong logic. All four are console-only here, no code.

Recovery

Password recovery (reset tokens, expiry, email delivery) needs two things set up before it works at all:

  1. Flows → Password Recovery. Toggle it on (same place, same on/off switch, as Self Registration below).
  2. Notification Channels → Email Provider → SMTP tab.
    • SMTP server hostname
    • SMTP port
    • From address
    • Username and password (SMTP auth); Gmail specifically needs an App Password, not the regular account password
    • Click Update
    • Reference: WSO2's Configure SMTP-based email provider guide
  3. Notification Channels → Email Templates.
    • Find the Password Recovery template
    • Customize subject/body if wanted
    • Keep the reset-link placeholder ({{link}} or similar) intact
    • Save

Enabling the Flow alone did not add a "Forgot password?" link to the embedded <SignIn /> form in testing; SMTP has to be configured too before recovery email can send. Whether SMTP alone is the full fix hasn't been confirmed live as of writing this: WSO2's own App-Native "Add login and logout" guide doesn't mention password recovery at all, so this may still only surface through a separate hosted page rather than the embedded form. Worth testing on camera rather than asserting either way.

Validation, expiration, and history count

All three live under one console page: Login & Registration → Login Security → Password Validation.

  1. Password Input Validation, the complexity rules:
    • Minimum/maximum length (defaults 8-64)
    • Minimum count of numbers required
    • Minimum count of uppercase characters required
    • Minimum count of lowercase characters required
    • Minimum count of special characters required
    • Minimum number of unique characters
    • Maximum repeated characters in a row
  2. Password History Count: block reusing any of the last N passwords (defaults to 5).
  3. Password Expiration:
    • Enforce globally across every login flow, or
    • Scope it to specific login flows only, via a "Password Reset Enforcer" step added to that flow
    • Rules can be layered, e.g. "eq 30 days" for one group, with a separate default for everyone else, evaluated top to bottom by priority

A user who signs in with an expired password gets forced through a change-password step at the end of the login flow automatically, no code on this app's side handles that redirect. All of these rules also apply to the password entered during self-registration below.

Add signup

Concept in play: still authentication, establishing who a new user is, not what they're allowed to do.

<SignUp /> is the app-native counterpart to <SignIn />, an embedded registration form, using whatever self-registration flow is configured under Flows (see "Configure an application" above, it's disabled by default).

// app/sign-up/page.tsx
"use client";
 
import { SignUp } from "@asgardeo/nextjs";
 
export default function SignUpPage() {
  return (
    <SignUp
      showTitle={false}
      showSubtitle={false}
      onError={(error) => console.error("Sign-up failed:", error)}
    />
  );
}

showTitle/showSubtitle turn off the card's own generic "Sign Up" heading and subtitle. Without them, the form renders that heading twice, since the registration flow itself also supplies a title. (The alternative, if you'd rather not touch the code: remove the title coming from the flow response using the registration flow builder UI in the console instead.)

The registration form's fields come straight from the Self Registration flow's configuration, this build uses Name, Email, Password, matching what sign-in expects (email as the login identifier). Edit the fields under Flows → Self Registration → the registration step in the Flow Builder.

Where the user lands after completing sign-up is controlled by afterSignInUrl on AsgardeoProvider (see "Configure the SDK" above); it applies to both sign-in and sign-up completion. Once the redirect URL from that section is registered in the console, the SDK signs the new user in automatically after registration and lands them straight on /dashboard, no separate sign-in step. If it ever has to skip that, the reason is printed in the browser console; set ASGARDEO_LOG_LEVEL=warn in .env.local to see the SDK's server-side warnings too.

Alternative: hosted-redirect SignUpButton, instead of app-nativeGo deeper

Same relationship as SignInButton/<SignIn />: SignUpButton redirects out to WSO2's hosted sign-up page instead of rendering the form inline:

import { SignUpButton } from "@asgardeo/nextjs";
 
<SignUpButton>Create an account</SignUpButton>;

Not used here, for the same reason SignInButton isn't: this build keeps the whole flow visually inside the app.

Add MFA using app-native APIs

Concept in play: why "roll your own auth" gets painful; MFA was called out there as real implementation time, here it's a console toggle instead.

No new component for this section, that's the point. Enable Email OTP as a second factor entirely in the console:

  1. Applications → your app → Login Flow. The existing flow already has a Username & Password step in the canvas.
  2. Add a second factor after it, either way:
    • Use the predefined "Add Multi-factor Login" shortcut, if the builder's toolbar offers it; it inserts a second-factor step automatically.
    • Or drag Email OTP in manually from the step/connector palette, and connect it to run after Username & Password, not in parallel.
  3. Update to save the flow.

Once enabled, the same <SignIn /> from above automatically walks the user through password entry, then an OTP-code step, then completes sign-in, no separate OTP input component to write. SMTP for sending the email is preconfigured out of the box on hosted WSO2 Identity Platform, so there's no mail server setup on your end.

Test from a fresh session (sign out or use an incognito window) to see the actual OTP prompt appear mid-flow after the password step.

Add Social Login using app-native APIs

Concept in play: OAuth 2.0 / OIDC, "Sign in with Google" is the exact authorization code + OIDC flow from the fundamentals section, just running against Google as the identity provider instead of WSO2 directly.

Also no new component. Configure Google as a Connection in the console:

  1. Connections tab → add Google, following WSO2's own social login setup guide.
  2. In Google Cloud Console, on that same OAuth 2.0 Client ID, register this app's own origin, not WSO2's domain:
    • Authorized JavaScript origin: http://localhost:3000 for local dev, or the deployed URL in production (e.g. https://your-app.vercel.app)
    • Authorized redirect URI: the same value and the afterSignInUrl-resolved URL from "Configure the SDK" above. http://localhost:3000 and http://localhost:3000/dashboard both need an entry here, for the same reason both needed registering on the WSO2 side
    • No trailing slash on any of these: Google's redirect_uri_mismatch check is byte-exact, so https://your-app.vercel.app/ and https://your-app.vercel.app count as different URIs even though they look identical; this is a genuinely easy way to hit Error 400: redirect_uri_mismatch after everything else is configured correctly, confirmed live
    • App-native auth means this app handles the Google callback first, before handing off to WSO2's APIs, unlike the hosted-redirect flow, where WSO2's own domain receives the Google callback directly
  3. Enable JIT (just-in-time) provisioning in the connector configuration; without it, profile fields (first/last name) can come back empty even though sign-in itself succeeds.
  4. Add Google as a first-factor sign-in option on this application's Login Flow. <SignIn /> then renders a "Sign in with Google" button alongside the password form automatically.

Social sign-up through the embedded <SignUp /> form isn't fully supported yet by WSO2 as of this writing, but with JIT provisioning enabled per step 3, that's not actually a gap in practice. A brand-new user who clicks "Continue with Google" on the sign-in form gets an account created automatically and lands on /dashboard signed in, social sign-up in one click, from the user's point of view, without needing the sign-up form to support it at all.

One caveat: for accounts created this way, /profile shows name, email, and picture from the ID token, but nothing on that page is editable. WSO2 has traced this to the platform's /scim2/Me endpoint returning a 404 for Google-provisioned users specifically, and is working on a fix. Users who register with email and password get the full editable profile (see "The /profile page" below).

Display user details

UserDropdown bundles both display and sign-out into one dropdown menu, avatar/name trigger, panel, sign-out action, all built in:

// app/components/SiteHeader.tsx
"use client";
 
import Link from "next/link";
import { SignedIn, SignedOut, UserDropdown } from "@asgardeo/nextjs";
 
export function SiteHeader() {
  return (
    <header>
      <SignedIn>
        <UserDropdown />
      </SignedIn>
      <SignedOut>
        <Link href="/sign-in">Sign In</Link>
      </SignedOut>
    </header>
  );
}
Alternatives: User render-prop, or the raw hook, for a simpler display than UserProfileGo deeper

User, render-prop, simplest option for a raw field:

import { User } from "@asgardeo/nextjs";
 
<User>
  {(user) => <span>{user.userName || user.username || user.sub}</span>}
</User>;

Raw hook, no component at all:

"use client";
import { useAsgardeo } from "@asgardeo/nextjs";
 
function Welcome() {
  const { isSignedIn, user } = useAsgardeo();
  return isSignedIn && user ? (
    <p>Welcome {user.userName || user.username || user.sub}</p>
  ) : null;
}

The user.userName || user.username || user.sub fallback chain isn't hedging, it's the pattern WSO2's own guides use, since the claim key for a display name can vary by user store.

The /profile page

UserProfile is a full editable panel (name, email, whatever the user store exposes), persisting changes back through the SCIM2 API automatically. This build uses it on its own /profile page:

// app/profile/page.tsx
"use client";
 
import { UserProfile, SignedIn } from "@asgardeo/nextjs";
 
export default function Profile() {
  return (
    <SignedIn>
      {/* renders editable fields sourced from the user's profile, and
          persists changes back through the SCIM2 API under the hood, no
          form-handling code of your own */}
      <UserProfile />
    </SignedIn>
  );
}
Watch out

NEXT_PUBLIC_ASGARDEO_SCOPES needs internal_login for this to work: the SCIM2 profile endpoint <UserProfile /> calls requires that scope, and without it the request is silently rejected. The panel falls back to whatever's in the ID token instead, which is why name can be missing entirely and nothing shows as editable even though the account genuinely has the data. Add internal_login to the scope list in .env.local, then sign out and back in; claims are baked into the token at sign-in, so an existing session won't pick up the new scope.

This only applies to users who registered with email and password. A user provisioned via "Continue with Google" (see "Add Social Login" above) sees name/email/picture from the ID token but can't edit anything on this page yet, a separate, known WSO2-side gap where /scim2/Me 404s for Google-provisioned accounts specifically.

Roles, groups, and protected routes

Concept in play: authorization and specifically RBAC; a signed-in user is already authenticated by this point, this section is entirely about the second, separate question of what they're allowed to do.

proxy.ts (above) already covers the sign-in check for /dashboard, /profile, and /admin via protectRoute(), a signed-out visitor hitting any of them is redirected to /sign-in automatically. That answers "is anyone signed in." Extending it to "does this person have the right role" means writing that check by hand. Role-based protection does not appear anywhere in WSO2's official guides (neither the React Complete Guide nor the App-Native Complete Guide covers a roles claim, an admin role, or a RequireRole component), so everything below is this build's own extrapolation on top of useAsgardeo().

Getting a role onto a user takes four steps in the console:

  1. User Management → Groups → New Group. Name it admin, assign your test user.
  2. User Management → Roles → New Role.
    • Name it admin
    • Set the audience to Application → pick this app → Next
    • Pick any API resource from the dropdown, check a permission box under it
    • Finish
  3. Open the role you just created → its Groups tab → add the admin group under Local GroupsUpdate.
  4. Add roles to the requested scopes (NEXT_PUBLIC_ASGARDEO_SCOPES in .env.local).

admin then shows up under user.roles on the client, visible directly via <UserProfile />'s "Roles" field.

user.roles can come back as a single string or an array, depending on how many roles a user has. Both RequireRole and AdminOnly below need to handle that, so it's worth its own shared file rather than copy-pasting the check twice:

// app/lib/normalizeRoles.ts
export function normalizeRoles(value: unknown): string[] {
  if (Array.isArray(value)) return value as string[];
  if (typeof value === "string") return [value];
  return [];
}
// app/components/RequireRole.tsx
"use client";
 
import { useRouter } from "next/navigation";
import { useEffect, type ReactNode } from "react";
import { useAsgardeo } from "@asgardeo/nextjs";
import { normalizeRoles } from "../lib/normalizeRoles";
 
export function RequireRole({
  allowed,
  children,
}: {
  allowed: string[];
  children: ReactNode;
}) {
  const { user, isLoading } = useAsgardeo();
  const router = useRouter();
 
  const roles = normalizeRoles(user?.roles);
  const isAllowed = roles.some((role) => allowed.includes(role));
 
  useEffect(() => {
    if (!isLoading && !isAllowed) router.replace("/dashboard");
  }, [isLoading, isAllowed, router]);
 
  if (isLoading || !isAllowed) return null;
  return <>{children}</>;
}
// app/admin/page.tsx
import { RequireRole } from "../components/RequireRole";
 
export default function AdminPanel() {
  return (
    <RequireRole allowed={["admin"]}>
      <div>
        <h1>Admin Panel</h1>
        <p>Only visible to users with the &quot;admin&quot; role.</p>
      </div>
    </RequireRole>
  );
}

A non-admin hitting /admin bounces to /dashboard; an admin gets through. A signed-out visitor bounces to /sign-in at the proxy.ts layer, before the role check ever runs.

Route guards block a whole page, but a page might have just one element only an admin should see. Same roles check, applied inline instead of at the route level:

// app/components/AdminOnly.tsx
"use client";
 
import type { ReactNode } from "react";
import { useAsgardeo } from "@asgardeo/nextjs";
import { normalizeRoles } from "../lib/normalizeRoles";
 
export function AdminOnly({ children }: { children: ReactNode }) {
  const { user } = useAsgardeo();
  const roles = normalizeRoles(user?.roles);
  return roles.includes("admin") ? <>{children}</> : null;
}

Manage tokens in app-native apps

Concepts in play: sessions vs. tokens, this app actually uses both, a session cookie holding tokens rather than a pure session-store or pure-JWT design, plus JWTs (the session cookie itself is one) and refresh token rotation.

Session persistence: this app's session lives in a signed, httpOnly JWT session cookie, set by the server, unreadable by client-side JavaScript. The actual access/refresh tokens never leave the server; the browser only ever holds a reference to them. There's no client-side storage location for an XSS payload to read from at all.

proxy.ts is also responsible for proactive token refresh: it checks the access token's exp claim on every request and, if it's within the SDK's refresh buffer window, exchanges the refresh token for a new access token before a Server Component render can see a stale one. Both the outgoing response cookie and the current request's forwarded headers get the new token, so the same-request render is never stale.

Logout (SignOutButton, inside UserDropdown above): the SDK clears the local session cookie, then redirects through WSO2's own sign-out endpoint so the session is terminated organization-wide too.

Securing your backend API

Concept in play: token verification; the JWT section explained verifying a signature locally, this section is the other half, verifying an opaque (non-JWT) token via introspection instead, since that's what WSO2 issues by default.

Why the frontend checks aren't enough

Everything built so far, proxy.ts's protectRoute(), RequireRole, AdminOnly, controls what renders in the browser. None of it is real security, because nothing stops someone from skipping the UI entirely and calling a backend API directly with curl or Postman, with no token at all, or with someone else's token. If there's a backend API behind this app, it has to check the token on every single request, independent of whatever the frontend already checked.

Opaque tokens vs. JWTs

The "JWTs, decoded" section earlier showed a token you can decode yourself with two lines of code, no network call needed, since a JWT carries its own claims and signature. WSO2's access tokens, by default, don't work that way: they're opaque, meaning they're just a random-looking string with no decodable content at all. The only way to know if one is valid is to ask WSO2 directly: "is this token still good, and who does it belong to?" That ask-the-provider mechanism has a name, OAuth 2.0 Token Introspection (RFC 7662), and it's a single HTTP request.

Step 1: find WSO2's introspection endpoint

Every WSO2 organization publishes a discovery document, one URL that lists all the other URLs (introspection, userinfo, token, etc.) for that org, so nothing has to be hardcoded:

// app/lib/verifyToken.ts
const baseUrl = process.env.NEXT_PUBLIC_ASGARDEO_BASE_URL;
 
let discoveryPromise: Promise<{
  introspection_endpoint: string;
  userinfo_endpoint: string;
}> | null = null;
 
function getDiscovery() {
  discoveryPromise ??= fetch(
    `${baseUrl}/oauth2/token/.well-known/openid-configuration`
  ).then((res) => res.json());
  return discoveryPromise;
}

discoveryPromise is cached in a module-level variable, so the fetch only happens once no matter how many requests hit this backend; every later call reuses the same promise instead of re-fetching the discovery document each time.

Note

The discovery URL lives at /oauth2/token/.well-known/openid-configuration, not the bare .well-known/openid-configuration path some OIDC guides use. Easy typo to make once, worth double-checking if this fetch ever 404s.

Step 2: ask WSO2 to introspect the token

Introspection is a POST request: send the token, get back whether it's still valid. WSO2 requires the caller (this backend) to prove its own identity first, using this app's own client ID and secret, sent as HTTP Basic auth, before it'll hand back information about anyone's token:

const clientId = process.env.NEXT_PUBLIC_ASGARDEO_CLIENT_ID; // not secret, just not bundled here
const clientSecret = process.env.ASGARDEO_CLIENT_SECRET;
const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
 
export async function verifyToken(token: string) {
  if (!token) return null;
 
  const discovery = await getDiscovery();
 
  const introspectResponse = await fetch(discovery.introspection_endpoint, {
    method: "POST",
    headers: {
      Authorization: `Basic ${basicAuth}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({ token }),
  });
  const introspection = await introspectResponse.json();
 
  if (!introspection.active) return null; // expired, revoked, or never existed
 
  return introspection;
}

At this point, verifyToken already does its core job: pass it a token, get back null if it's dead, or the introspection response (containing things like scope, client_id, exp) if it's alive.

Step 3: get role claims from a second endpoint

Try using this as-is to check the admin role, though, and it won't work: introspection doesn't return role or group claims, only validity and authorization metadata. Claims about who the user is (roles, groups, email) live on a separate endpoint, /userinfo, called with the user's own token as a Bearer token rather than the app's client credentials:

export async function verifyToken(token: string) {
  if (!token) return null;
 
  try {
    const discovery = await getDiscovery();
 
    const introspectResponse = await fetch(discovery.introspection_endpoint, {
      method: "POST",
      headers: {
        Authorization: `Basic ${basicAuth}`,
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: new URLSearchParams({ token }),
    });
    const introspection = await introspectResponse.json();
 
    if (!introspection.active) return null;
 
    const userinfoResponse = await fetch(discovery.userinfo_endpoint, {
      headers: { Authorization: `Bearer ${token}` },
    });
    const userinfo = await userinfoResponse.json().catch(() => ({}));
 
    return { ...introspection, ...userinfo }; // roles come from userinfo, not introspection
  } catch {
    return null;
  }
}

Two calls per request, but that's the actual OIDC split: introspection proves the token is real, userinfo says who it belongs to.

Step 4: use it in an actual route

verifyToken is a plain function, it works the same wherever it's called from. Here it's wired into a Next.js API route:

// app/api/admin/stats/route.ts, the server-side version of RequireRole
import { NextRequest, NextResponse } from "next/server";
import { verifyToken } from "@/app/lib/verifyToken";
 
export async function GET(req: NextRequest) {
  const token = (req.headers.get("authorization") ?? "").replace("Bearer ", "");
  const user = await verifyToken(token);
 
  if (!user) {
    return NextResponse.json(
      { error: "invalid or expired token" },
      { status: 401 }
    );
  }
 
  const rawRoles = user.roles;
  const roles = Array.isArray(rawRoles)
    ? rawRoles
    : typeof rawRoles === "string"
      ? [rawRoles]
      : [];
  if (!roles.includes("admin")) {
    return NextResponse.json({ error: "forbidden" }, { status: 403 });
  }
 
  return NextResponse.json({ totalUsers: 4213 });
}

Three real responses to actually try: signed in without the admin role → 403; signed in with the admin role (set up via WSO2's Roles system, see "Roles, groups, and protected routes" above) → 200, {"totalUsers":4213}; no Authorization header at all → 401 (though that one can't be triggered from the dashboard's own button, since proxy.ts already blocks a signed-out visitor from loading /dashboard in the first place). Hit the route directly instead: curl -i http://localhost:3000/api/admin/stats.

Why a separate backend service, not a Next.js API route, might be worth it anywayGo deeper

A standalone backend (Express, or anything else) proves the token check works independent of the frontend framework, useful if the "backend" in your own project is a different service entirely, not part of the Next.js app. The tradeoff: it's a second thing to deploy and keep reachable, which is exactly what breaks a demo like this one if the two are deployed separately and only one of them goes live. A Next.js API route ships with the same build and deployment as everything else here, at the cost of no longer demonstrating protection of a genuinely external service.

Step 5: call the protected route from the client

One more wrinkle: the access token needed for the Authorization header lives server-side only, getAccessToken() is a server-only SDK function, never exposed to client-side JavaScript (that's the point of app-native's session model). A Client Component that wants to call /api/admin/stats itself has to go through a Server Action to get the token first:

// app/dashboard/actions.ts
"use server";
 
import { asgardeo } from "@asgardeo/nextjs/server";
 
export async function fetchAccessToken() {
  const { getSessionId, getAccessToken } = await asgardeo();
  const sessionId = await getSessionId();
  if (!sessionId) return undefined;
  return getAccessToken(sessionId);
}
// app/dashboard/page.tsx
"use client";
 
import { useState } from "react";
import { AdminOnly } from "../components/AdminOnly";
import { fetchAccessToken } from "./actions";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
 
function deleteUser() {
  alert("Pretend a user just got deleted.");
}
 
function useProtectedFetch() {
  return async (url: string) => {
    const token = await fetchAccessToken();
    if (!token) throw new Error("No access token — not signed in.");
    return fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  };
}
 
function AdminStatsDemo() {
  const protectedFetch = useProtectedFetch();
  const [status, setStatus] = useState<number | null>(null);
  const [body, setBody] = useState<unknown>(null);
  const [networkError, setNetworkError] = useState<string | null>(null);
 
  const callAdminStats = async () => {
    setNetworkError(null);
    try {
      const res = await protectedFetch("/api/admin/stats");
      setStatus(res.status);
      setBody(await res.json().catch(() => null));
    } catch {
      setStatus(null);
      setBody(null);
      setNetworkError("Couldn't reach /api/admin/stats.");
    }
  };
 
  return (
    <Card>
      <CardContent className="flex flex-col gap-4">
        <Button onClick={callAdminStats} className="self-start">
          Call /api/admin/stats
        </Button>
        {networkError && (
          <p className="text-sm text-destructive">{networkError}</p>
        )}
        {status !== null && (
          <pre className="overflow-x-auto rounded-lg bg-muted p-4 text-xs">
            {status} {JSON.stringify(body, null, 2)}
          </pre>
        )}
      </CardContent>
    </Card>
  );
}
 
export default function Dashboard() {
  return (
    <div className="flex flex-col gap-6">
      <div>
        <h1 className="text-3xl font-semibold tracking-tight">Dashboard</h1>
        <p className="text-muted-foreground">
          Every signed-in user can see this page.
        </p>
        <p className="text-sm text-muted-foreground">
          Client-side checks are just UX — the real security boundary is the
          backend verifying the token on every request.
        </p>
      </div>
 
      {/* Same "roles" check as RequireRole, applied inline instead of gating the whole page. */}
      <AdminOnly>
        <Button
          onClick={deleteUser}
          variant="destructive"
          className="self-start"
        >
          Delete user
        </Button>
      </AdminOnly>
 
      <AdminStatsDemo />
    </div>
  );
}
Note

Since the raw access token never reaches client-side JavaScript (the security benefit from the "Manage tokens" section above), a Client Component that needs it for its own fetch call has no direct way to read it, it has to go through a Server Action like fetchAccessToken above. AdminOnly (covered in "Roles, groups, and protected routes") hides the delete button for non-admins; AdminStatsDemo is the actual proof, try it signed in as a non-admin (403) and as an admin (200).

Looking ahead to 2026

A few trends worth knowing about, not necessarily adopting immediately: passkeys and biometric login are moving from nice-to-have to expected on consumer apps; users increasingly expect passwordless and social options by default; and AI-assisted login/registration flows are becoming a real differentiator for conversion.

Note

WSO2 is also building Agent ID / MCP Auth, treating AI agents as first-class identities so agentic systems can authenticate the same way users do. Worth a passing mention if you're starting to run AI agents against your own APIs, even though this build doesn't touch it.

What you'd build vs. what's included

You'd have to build and maintain yourselfHandled by WSO2 Identity Platform
Password hashing (bcrypt/argon2, salted)Embedded or hosted login form, out of the box
Session storage strategySigned, httpOnly session cookie, server-managed, one line to enable
Refresh token rotation and reuse detectionBuilt into the SDK's proactive refresh (proxy.ts)
Token verification (JWT locally, opaque via introspection)A jwtVerify call or an introspection request, either a few lines
MFA ceremonies (TOTP, passkeys)Toggle in the application's Login Flow
Social OAuth app registration per providerToggle in Connections
Session/account revocation at scaleConsole-managed

The DIY path isn't wrong, it means full control, no vendor dependency, and no per-active-user pricing to think about, just real engineering time to build and maintain every item on the left column yourself. Which side makes sense depends entirely on how much of that time your project actually has. Everything above authentication itself, data types, constraints, business logic, is yours either way; what changes is only how much of the identity plumbing you build versus configure.

Want to build a full Next.js project?

See how a real Next.js + Supabase project comes together end to end.

Watch the Next.js + Supabase playlist