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.
- 01Authentication vs. authorization
- 02Sessions vs. tokens
- 03Refresh tokens and rotation
- 04JWTs, decoded
- 05OAuth 2.0 and OIDC, in plain English
- 06Why "just roll your own auth" gets painful
- 07Building it with WSO2 Identity Platform
- Configure an application
- Create the Next.js app
- Configure the SDK
- Add login and logout
- Customize the form copy
- Manage passwords
- Add signup
- Add MFA using app-native APIs
- Add Social Login using app-native APIs
- Display user details
- The /profile page
- Roles, groups, and protected routes
- Manage tokens in app-native apps
- Securing your backend API
- Looking ahead to 2026
- 08What you'd build vs. what's included
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.
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.
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 likeadmin_billing_onlythat 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
prosubscription 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.
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.
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.
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."
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.
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 }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.
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) andaud(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/nbfchecks 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
kidin 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 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_tokenscoped tocalendar.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/userinfoendpoint.
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:
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.
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:
- The client generates a random
code_verifierlocally, and sends only its hashed form, thecode_challenge, when starting the/authorizerequest. - The identity provider stores that challenge against the code it issues.
- 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.
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
stateparameter (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 courseBuilding 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.
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:
- Applications → New Application → pick the Next.js quick-start template. This is what exposes the app-native authentication toggle used throughout this build.
- Complete the wizard: name it (e.g.
react-auth-demo) and set an authorized redirect URL,http://localhost:3000for local dev, matching the Next.js dev server default. Has to match exactly where the app is actually served. - 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.)
- 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-demoWSO2'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-menuConfirm 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/nextjsCreate .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_SECREThas noNEXT_PUBLIC_prefix, deliberately. Next.js only bundlesNEXT_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_SECRETis a separate variable fromASGARDEO_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 withopenssl rand -base64 32and 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.
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:
- Flows → Password Recovery. Toggle it on (same place, same on/off switch, as Self Registration below).
- 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
- 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.
- 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
- Password History Count: block reusing any of the last N passwords (defaults to 5).
- 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:
- Applications → your app → Login Flow. The existing flow already has a Username & Password step in the canvas.
- 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.
- 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:
- Connections tab → add Google, following WSO2's own social login setup guide.
- 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:3000for 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:3000andhttp://localhost:3000/dashboardboth 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_mismatchcheck is byte-exact, sohttps://your-app.vercel.app/andhttps://your-app.vercel.appcount as different URIs even though they look identical; this is a genuinely easy way to hitError 400: redirect_uri_mismatchafter 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
- Authorized JavaScript origin:
- 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.
- 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>
);
}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:
- User Management → Groups → New Group. Name it
admin, assign your test user. - 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
- Name it
- Open the role you just created → its Groups tab → add the
admingroup under Local Groups → Update. - Add
rolesto the requested scopes (NEXT_PUBLIC_ASGARDEO_SCOPESin.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 "admin" 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.
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>
);
}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.
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 yourself | Handled by WSO2 Identity Platform |
|---|---|
| Password hashing (bcrypt/argon2, salted) | Embedded or hosted login form, out of the box |
| Session storage strategy | Signed, httpOnly session cookie, server-managed, one line to enable |
| Refresh token rotation and reuse detection | Built 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 provider | Toggle in Connections |
| Session/account revocation at scale | Console-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