Scenario. PixieShop is a B2B SaaS that sells enchanted toys to retail partners. Every partner logs in through their own corporate identity provider — some use Okta, others use Microsoft Entra ID. We never see a password. FusionAuth sits in the middle as our OIDC identity broker, and Auth.js v5 handles the Next.js session on the frontend. This playbook is the single place where we document how the whole chain works, from RSA key creation to federated logout.
1. Architecture Overview — Who Talks to Whom
The authentication chain has four actors:
- Partner’s browser — opens PixieShop, clicks “Sign in with SSO.”
- Auth.js v5 (Next.js) — redirects to FusionAuth with the right
idp_hint. - FusionAuth (identity broker) — looks at the email domain, forwards to Okta or Entra ID.
- External IdP (Okta or Entra ID) — authenticates the user, returns an authorization code.
The flow goes like this: Browser → Auth.js → FusionAuth → External IdP → FusionAuth → Auth.js → Browser. Every hop is a standard OIDC Authorization Code exchange. FusionAuth never stores passwords — it only stores the mapping between the external sub and our internal PixieShop user ID.
Browser Auth.js FusionAuth Okta / Entra
(Partner) (Next) (Broker) (Ext. IdP)
Why a broker? Without FusionAuth, Auth.js would need a separate provider config for every partner’s IdP. With the broker, Auth.js talks to exactly one OIDC issuer. Adding a new partner IdP is a FusionAuth admin task — zero code changes.
2. FusionAuth — Tenant and RSA Key Setup
FusionAuth organises everything under Tenants. Each tenant has its own issuer URL, its own signing keys, and its own set of applications. For PixieShop we use a single tenant.
2.1 Configure the Tenant Issuer
Navigate to FusionAuth Admin → Tenants → [PixieShop Tenant] → General Tab.
Set the Issuer field to https://auth.pixieshop.com (include the https:// — FusionAuth will reject a bare domain). Save, then verify by visiting https://auth.pixieshop.com/.well-known/openid-configuration. You should get back a JSON document with issuer, jwks_uri, authorization_endpoint, and friends.
2.2 Create RSA Signing Keys
Navigate to Settings → Key Master → Add.
Choose RSA, 2048-bit. Name it something recognisable like PixieShop JWT Signing Key. Save and note the Key ID — you will need it in the next step.
2.3 Assign Keys to the Tenant
Navigate to Tenants → [PixieShop Tenant] → JWT Tab.
Set both the Access token signing key and the Id token signing key to the RSA key you just created. Save. Verify by visiting https://auth.pixieshop.com/.well-known/jwks.json — you should see the public key in JWK format.
Why RSA and not HMAC? External IdPs need to verify our tokens without sharing a secret. RSA gives us a public key we can expose at the JWKS endpoint. HMAC would require sharing the secret with every consumer.
3. FusionAuth — Application Configuration
An “Application” in FusionAuth maps to a client application — in our case, the PixieShop Next.js frontend.
3.1 Create the Application
Navigate to Applications → Add.
Name it PixieShop Partner Dashboard. Select the PixieShop tenant.
3.2 OAuth Settings
Open the OAuth Tab and configure:
Authorized redirect URLs:
http://localhost:3000/api/auth/callback/fusionauth
https://app.pixieshop.com/api/auth/callback/fusionauth
Authorized origins (CORS):
http://localhost:3000
https://app.pixieshop.com
Logout URLs:
http://localhost:3000/auth/logout
https://app.pixieshop.com/auth/logout
Grants enabled: Authorization Code and Refresh Token. Do not enable Implicit — it is deprecated in OAuth 2.1.
3.3 JWT Settings
Open the JWT Tab. Uncheck “Enabled” — let the application inherit JWT settings from the tenant level. This keeps signing keys in one place.
3.4 Roles (Optional)
Open the Roles Tab. Add user (default) and admin. Set user as the default role.
3.5 Note Your Credentials
From the OAuth Tab, copy the Client ID (UUID) and Client Secret. You will need both for Auth.js.
4. FusionAuth — Identity Provider (OIDC) Wiring
This is where we tell FusionAuth: “When you see an email from @toyworld.com, send them to Okta. When you see @enchantedretail.onmicrosoft.com, send them to Entra ID.”
4.1 Add an OpenID Connect IdP (Okta Example)
Navigate to Settings → Identity Providers → Add → OpenID Connect.
Fill in:
| Field | Value |
|---|---|
| Name | Okta — ToyWorld |
| Client ID | (from Okta app registration) |
| Client Secret | (from Okta app registration) |
| Issuer | https://toyworld.okta.com/oauth2/default |
| Scope | openid email profile |
4.2 Claims Mapping
| FusionAuth Field | Claim Value |
|---|---|
| Unique Id claim | sub |
| Email claim | email |
| Username claim | preferred_username |
Gotcha: Do not use
preferred_usernameclaim is more reliable because Okta always populates it for OIDC apps, while
4.3 Managed Domains
Add toyworld.com. Any user whose email ends with @toyworld.com will be routed to this IdP automatically by FusionAuth’s domain-based routing.
4.4 Application Enablement
Enable the IdP for the PixieShop Partner Dashboard application. Set Create registration to OFF — we handle user provisioning on our side.
Save and note the Identity Provider ID (UUID). Auth.js will pass this as idp_hint to skip the FusionAuth login screen.
4.5 Repeat for Entra ID
Same process, different values:
| Field | Value |
|---|---|
| Name | Microsoft — EnchantedRetail |
| Client ID | (from Entra ID app registration) |
| Client Secret | (from Entra ID — copy the secret Value, not the ID) |
| Issuer | https://login.microsoftonline.com/{tenantId}/v2.0 |
| Scope | openid email profile |
| Managed domains | enchantedretail.onmicrosoft.com |
For Entra ID claims mapping, use sub or oid as the Unique Id claim, email as the Email claim, and preferred_username as the Username claim.
idp_hint is proprietary to FusionAuth. It is not a standard OIDC parameter. It is the FusionAuth Identity Provider UUID, passed as a query parameter on the authorize endpoint to bypass the broker’s login screen and jump straight to the external IdP.
5. Okta — OIDC Application and Authorization Server
5.1 Create the OIDC Application
In the Okta Admin Console → Applications → Create App Integration:
- Sign-in method: OIDC — OpenID Connect
- Application type: Web Application
- Name:
PixieShop SSO - Grant types: Authorization Code, Refresh Token
- Sign-in redirect URI:
https://auth.pixieshop.com/oauth2/callback - Sign-out redirect URI:
https://auth.pixieshop.com/oauth2/logout/callback - Controlled access: Limit to selected groups
Note the Client ID, Client Secret, and Issuer (https://toyworld.okta.com/oauth2/default).
5.2 Authorization Server Policy — The Step Everyone Forgets
This is the number one cause of Okta 400 errors. Without an access policy on the Authorization Server, Okta will reject every authorize request.
Navigate to Security → API → Authorization Servers → default.
Custom vs Org Authorization Server. Okta has two types. The Org Authorization Server lives at
/oauth2/v1/*and is meant for Okta’s own APIs. The Custom Authorization Server (nameddefault) lives at/oauth2/default/v1/*and is what you use for your own apps. Always use the Custom (default) server for third-party OIDC integrations. If you see endpoints likehttps://toyworld.okta.com/oauth2/v1/authorizewithout the/default/segment, you are hitting the Org server and things will break.
The correct endpoints are:
Authorization: https://toyworld.okta.com/oauth2/default/v1/authorize
Token: https://toyworld.okta.com/oauth2/default/v1/token
UserInfo: https://toyworld.okta.com/oauth2/default/v1/userinfo
Create the access policy:
- Click Access Policies → Add Policy
- Name:
PixieShop Integration Policy - Assign to: the PixieShop SSO application
Add a rule:
- Rule Name:
Allow Assigned Users - Grant type: Authorization Code
- User is: Any user assigned the app
- Scopes:
openid,profile,email - Create Rule
Without this policy you will see either an HTTP 400 on the authorize endpoint (if the user is not yet authenticated at Okta) or a “You are not allowed to access this app” message (if the user is already authenticated).
5.3 Okta Plan Considerations
As of July 2025, Okta retired the Developer Edition org. The replacement is the Integrator Free Plan, which gives you a non-production org with up to 10 active users — enough for testing. Sign up at developer.okta.com. If you had a Developer Edition, you must migrate — your old org will be deactivated.
6. Microsoft Entra ID — App Registration and Token Claims
6.1 Register the Application
Navigate to entra.microsoft.com → Identity → Applications → App registrations → New registration.
Do not use the old “Azure Portal → Azure Active Directory” path. Microsoft renamed Azure AD to Microsoft Entra ID in 2023 and has been migrating admin surfaces to
entra.microsoft.com. The old Azure Portal path still works but is deprecated, and some new features only appear in the Entra admin center.
- Name:
PixieShop Partner SSO - Supported account types: Single tenant (EnchantedRetail’s org only)
- Redirect URI:
https://auth.pixieshop.com/oauth2/callback
6.2 Client Secret
Navigate to Certificates & secrets → New client secret. Copy the secret Value (not the ID column). The Value is only shown once.
6.3 Token Configuration — The Email Claim Breaking Change
This is critical. In June 2023, Microsoft changed the default behavior for the email claim in ID tokens. For multi-tenant apps, the email claim is no longer included unless the domain owner has verified it. Even for single-tenant apps, the email claim may be absent for users who do not have a mail attribute set in Entra ID.
Navigate to Token configuration → Add optional claim → ID token and select:
emailpreferred_usernameprofile
For authorization decisions, never rely on email or upn as a stable identifier. These are mutable — an admin can change a user’s email at any time. Instead, use oid (Object ID) combined with tid (Tenant ID) as your immutable user identifier. The sub claim is also stable but is pairwise — it changes per application registration, so oid + tid is more portable if you ever need cross-app correlation.
6.4 Note Your Credentials
- Application (client) ID
- Directory (tenant) ID
- Client Secret Value
- Issuer:
https://login.microsoftonline.com/{tenantId}/v2.0
Entra ID endpoints:
Authorization: https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/authorize
Token: https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token
UserInfo: https://graph.microsoft.com/oidc/userinfo
7. Auth.js v5 — Provider Setup
Auth.js v5 (the library formerly known as NextAuth.js) unifies everything into a single auth() function exported from a root auth.ts file. No more getServerSession(req, res, authOptions) — that is the v4 pattern.
7.1 The auth.ts Configuration
// auth.ts (project root)
import NextAuth from "next-auth";
const FusionAuthProvider = {
id: "fusionauth",
name: "FusionAuth",
type: "oidc" as const,
issuer: process.env.AUTH_FUSIONAUTH_ISSUER,
clientId: process.env.AUTH_FUSIONAUTH_ID,
clientSecret: process.env.AUTH_FUSIONAUTH_SECRET,
authorization: {
params: {
scope: "openid profile email offline_access",
},
},
};
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [FusionAuthProvider],
pages: { signIn: "/auth" },
callbacks: {
jwt({ token, account }) {
if (account) {
token.idToken = account.id_token;
token.fusionAuthId = account.providerAccountId;
}
return token;
},
session({ session, token }) {
if (session.user) {
session.user.id = token.fusionAuthId as string;
}
return session;
},
},
});
Key v5 changes from v4:
- Environment variables use the
AUTH_prefix:AUTH_SECRET(notNEXTAUTH_SECRET),AUTH_URL(notNEXTAUTH_URL). Auth.js v5 also infersAUTH_URLfrom request headers in many deployments, so you may not need to set it explicitly.- The provider field
clientIdmaps toAUTH_FUSIONAUTH_ID(notFUSIONAUTH_CLIENT_ID). Auth.js v5 auto-readsAUTH_{PROVIDER}_{FIELD}env vars.- The
signIn()function takesredirectTo(notcallbackUrl).- The session cookie is named
authjs.session-token(notnext-auth.session-token).
7.2 Route Handler (App Router)
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
7.3 Using auth() Everywhere
// In a Server Component
import { auth } from "@/auth";
export default async function DashboardPage() {
const session = await auth();
if (!session) redirect("/auth");
return <h1>Welcome, {session.user?.name}</h1>;
}
// In Middleware
// middleware.ts (or proxy.ts in Next.js 16+)
import { auth } from "@/auth";
export default auth((req) => {
if (!req.auth && req.nextUrl.pathname !== "/auth") {
return Response.redirect(new URL("/auth", req.nextUrl));
}
});
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*"],
};
In Auth.js v5 middleware, the session is available on
req.auth— you do not need to callgetToken()separately. ThegetToken({ req })pattern is v4-only.
8. Auth.js v5 — Email-Based IdP Routing (idp_hint)
When a partner types their email on the PixieShop login page, we extract the domain and pass the corresponding FusionAuth Identity Provider ID as idp_hint. This skips the FusionAuth login screen entirely and sends the user straight to their corporate IdP.
// app/auth/components/EmailSSOForm.tsx
"use client";
import { signIn } from "next-auth/react";
const IDP_MAP: Record<string, string | undefined> = {
"toyworld.com": process.env.NEXT_PUBLIC_OKTA_IDP_ID,
"enchantedretail.onmicrosoft.com": process.env.NEXT_PUBLIC_ENTRA_IDP_ID,
};
export function EmailSSOForm() {
async function handleSubmit(formData: FormData) {
const email = formData.get("email") as string;
const domain = email.split("@")[1]?.toLowerCase();
const idpId = domain ? IDP_MAP[domain] : undefined;
await signIn(
"fusionauth",
{ redirectTo: "/dashboard" },
{
login_hint: email,
...(idpId && { idp_hint: idpId }),
},
);
}
return (
<form action={handleSubmit}>
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" required />
<button type="submit">Continue with SSO</button>
</form>
);
}
Notice we pass redirectTo (v5), not callbackUrl (v4). The third argument to signIn() contains additional authorization parameters that Auth.js forwards to FusionAuth.
9. FusionAuth — Theme Customization (Login Screen)
FusionAuth themes let you control the HTML/CSS of the login, error, and email templates. Theme customization requires a paid plan (Starter or above — the Community Edition does not support it).
Pricing clarification. FusionAuth’s paid tiers are Community (free, no theming), Starter, Essentials, and Enterprise. The old “Essentials” name you may see in some docs has been restructured — check
fusionauth.io/pricingfor the current breakdown.
9.1 Template Structure
FusionAuth uses FreeMarker as its template engine. The key templates are:
- OAuth authorize — the main login page
- OAuth error — the error page
- Stylesheet — shared CSS
9.2 FreeMarker Variables
The correct FreeMarker variables (some older docs get these wrong):
| Variable | What it contains |
|---|---|
${tenant.name!''} |
Tenant display name |
${client_id!''} |
OAuth client ID of the requesting app |
${loginId!''} |
Pre-filled login identifier |
${redirect_uri!''} |
Where to go after auth |
Common mistake: Using
${applicationName}or${action}— these do not exist in FusionAuth’s OAuth authorize template context. The application name is not directly available; use${tenant.name}or hard-code your app name. The form action URL is the current page URL, so use a regular<form method="post">without an explicit action attribute, or use FreeMarker’s request helpers.
9.3 Email Prefill Script
Add this before </body> in the OAuth authorize template to pre-fill and lock the email field when login_hint is present:
<script>
(function () {
var params = new URLSearchParams(window.location.search);
var hint = params.get("login_hint");
if (!hint) return;
var el = document.getElementById("loginId");
if (el) {
el.value = hint;
el.readOnly = true;
el.setAttribute("tabindex", "-1");
}
})();
</script>
9.4 Styling for Design System Consistency
Keep the CSS simple. Use CSS custom properties so dark mode is a one-block override:
:root {
--ps-primary: #6c3fc5;
--ps-bg: #faf8ff;
--ps-text: #1a1a2e;
--ps-border: #e0dce8;
--ps-radius: 6px;
--ps-font: "Inter", system-ui, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--ps-bg: #1a1a2e;
--ps-text: #e8e6f0;
--ps-border: #3a3650;
}
}
The full CSS should match your product’s design tokens. Include font-size: 16px on inputs to prevent iOS auto-zoom.
10. Federated Logout — Clearing Sessions Across All Layers
SSO logout is tricky because sessions exist in three places: the browser (Auth.js cookie), FusionAuth, and the external IdP. A proper federated logout clears all three.
10.1 The Logout Chain
- User clicks “Sign out” in PixieShop.
- Frontend calls our server-side logout action.
- Server reads
id_tokenfrom the Auth.js session. - Server redirects to FusionAuth’s
/oauth2/logoutwithid_token_hintandpost_logout_redirect_uri. - FusionAuth clears its session and (if configured) sends a logout request to the external IdP.
- FusionAuth redirects back to
post_logout_redirect_uri. - Auth.js session cookie is cleared.
10.2 Server Action (App Router)
// app/auth/actions.ts
"use server";
import { auth, signOut } from "@/auth";
export async function federatedSignOut() {
const session = await auth();
const idToken = (session as any)?.idToken;
if (!idToken) {
// No id_token — just clear the local session
await signOut({ redirectTo: "/auth" });
return;
}
const logoutUrl = new URL(
`${process.env.AUTH_FUSIONAUTH_ISSUER}/oauth2/logout`,
);
logoutUrl.searchParams.set("id_token_hint", idToken);
logoutUrl.searchParams.set(
"post_logout_redirect_uri",
`${process.env.AUTH_URL}/auth/logout`,
);
// Clear Auth.js session first, then redirect to FusionAuth logout
await signOut({ redirect: false });
redirect(logoutUrl.toString());
}
Why not
res.redirect()? In Auth.js v5 with the App Router, we do not have access to the rawresobject. Use Next.jsredirect()fromnext/navigationfor server actions, or return a redirect Response from a Route Handler.
10.3 Logout Landing Page
// app/auth/logout/page.tsx
import { signOut } from "@/auth";
export default async function LogoutPage() {
// This page is the post_logout_redirect_uri target.
// Auth.js session should already be cleared, but just in case:
await signOut({ redirect: false });
return (
<div>
<h1>You have been signed out</h1>
<a href="/auth">Sign in again</a>
</div>
);
}
10.4 Critical: Logout URL Must Match
The post_logout_redirect_uri you pass to FusionAuth must exactly match one of the “Logout URLs” configured in the FusionAuth application OAuth settings. A trailing slash mismatch will cause FusionAuth to silently ignore the redirect and show its own generic logged-out page.
11. Role-Based Routing and Database Linking
11.1 Linking FusionAuth Users to PixieShop Users
When a partner logs in via SSO for the first time, we need to connect their FusionAuth identity to their PixieShop database record. We use a fusionAuthId column:
ALTER TABLE "user" ADD COLUMN "fusionAuthId" VARCHAR UNIQUE;
CREATE INDEX idx_user_fusion_auth_id ON "user"("fusionAuthId");
The linking happens lazily on first SSO login:
// lib/auth/link-user.ts
import { db } from "@/db";
import { user } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function linkOrCreateUser(email: string, fusionAuthId: string) {
// Try to find by fusionAuthId first (returning user)
let existing = await db.query.user.findFirst({
where: eq(user.fusionAuthId, fusionAuthId),
});
if (existing) return existing;
// Try to find by email (first-time SSO)
existing = await db.query.user.findFirst({
where: eq(user.email, email),
});
if (existing) {
await db.update(user).set({ fusionAuthId }).where(eq(user.id, existing.id));
return { ...existing, fusionAuthId };
}
// JIT provisioning: create new user
const [newUser] = await db
.insert(user)
.values({ email, fusionAuthId })
.returning();
return newUser;
}
11.2 Role-Based Middleware
// middleware.ts
import { auth } from "@/auth";
export default auth((req) => {
if (!req.auth) {
return Response.redirect(new URL("/auth", req.nextUrl));
}
// Admin routes require admin role
if (req.nextUrl.pathname.startsWith("/admin")) {
const roles = (req.auth as any).roles ?? [];
if (!roles.includes("admin")) {
return Response.redirect(new URL("/dashboard", req.nextUrl));
}
}
});
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*"],
};
11.3 Injecting Roles into the Session
FusionAuth includes roles in the ID token under the roles claim (if you configured roles on the application). Capture them in the JWT callback:
// auth.ts — extend the jwt callback
callbacks: {
jwt({ token, account, profile }) {
if (account) {
token.idToken = account.id_token;
token.fusionAuthId = account.providerAccountId;
token.roles = (profile as any)?.roles ?? [];
}
return token;
},
session({ session, token }) {
if (session.user) {
session.user.id = token.fusionAuthId as string;
(session as any).roles = token.roles;
}
return session;
},
},
Do not decode the
id_tokenmanually on the client. The old pattern ofimport jwt_decode from "jwt-decode"(default import) is wrong —jwt-decodev4 uses named exports:import { jwtDecode } from "jwt-decode". But more importantly, you should not need it. Auth.js already decodes the token and gives you the claims in theprofileparameter of thejwtcallback. Let the server handle tokens; the client gets the session object.
12. Environment Variables — Complete Reference
# Auth.js v5
AUTH_SECRET="openssl rand -base64 33" # generate with this command
AUTH_URL=https://app.pixieshop.com # optional in Vercel/auto-detected
# FusionAuth (Auth.js provider)
AUTH_FUSIONAUTH_ISSUER=https://auth.pixieshop.com
AUTH_FUSIONAUTH_ID=<fusionauth-client-id>
AUTH_FUSIONAUTH_SECRET=<fusionauth-client-secret>
# IdP Routing (public, safe for browser)
NEXT_PUBLIC_OKTA_IDP_ID=<fusionauth-okta-identity-provider-uuid>
NEXT_PUBLIC_ENTRA_IDP_ID=<fusionauth-entra-identity-provider-uuid>
# Domain Mapping (server-side only)
OKTA_MANAGED_DOMAINS=toyworld.com
ENTRA_MANAGED_DOMAINS=enchantedretail.onmicrosoft.com
Auth.js v5 naming convention. Provider-specific env vars follow the pattern
AUTH_{PROVIDER_ID}_{FIELD}. Auth.js reads them automatically, so you can omitclientIdandclientSecretfrom the provider object if you name your env vars correctly. We keep them explicit in the config for clarity.
13. Troubleshooting — Error Patterns and Fixes
jwks_uri must be configured
Cause: RSA keys were not assigned to the tenant’s JWT settings.
Fix: Tenants → JWT Tab → set both signing keys → restart Next.js to flush the OIDC discovery cache.
invalid_redirect_uri
Cause: The callback URL Auth.js sends does not exactly match what is registered in FusionAuth.
Fix: Check Applications → OAuth Tab → “Authorized redirect URLs”. The URL must be an exact match — no trailing slash differences, no http vs https mismatch.
invalid_client
Cause: Client ID or Client Secret mismatch.
Fix: Re-copy both from FusionAuth Applications → General Tab. Update .env.local and restart.
Okta 400 on /authorize
Cause: No access policy on the Okta Custom Authorization Server for your client.
Fix: Security → API → Authorization Servers → default → Access Policies. Create a policy bound to your app with a rule allowing Authorization Code grants. See section 5.2.
Okta “You are not allowed to access this app”
Cause: Same as above — the user is already authenticated at Okta but there is no policy allowing the token grant.
Entra ID missing email claim
Cause: The June 2023 breaking change. Multi-tenant apps no longer receive email by default.
Fix: Token configuration → Add optional claim → ID token → select email. For single-tenant apps this usually works. For multi-tenant apps, the email domain must be verified by the tenant admin.
Entra ID invalid_client
Cause: Copied the secret ID instead of the secret Value from Certificates & secrets.
Fix: Go back to Entra → Certificates & secrets. If you can no longer see the Value, create a new secret — the Value is only shown once at creation time.
Auth.js session cookie not set
Cause: Possibly using the old cookie name next-auth.session-token in your checks. Auth.js v5 uses authjs.session-token.
Fix: Update any cookie-reading code. In most cases, use auth() server-side — do not read cookies directly.
import type { } = from "next" syntax error
Cause: A TypeScript typo — the = sign does not belong there.
Fix: The correct syntax is import type { NextApiRequest, NextApiResponse } from "next" (no =).
14. Testing Checklist
Happy Path
- [ ] Partner with Okta-managed domain signs in → redirected to Okta → returns to PixieShop dashboard
- [ ] Partner with Entra-managed domain signs in → redirected to Entra → returns to PixieShop dashboard
- [ ] Email prefill: login_hint populates and locks the email field on FusionAuth login screen
- [ ] First-time user:
fusionAuthIdcolumn gets populated in the database - [ ] Returning user: login succeeds without re-linking
Federated Logout
- [ ] Click logout → FusionAuth session cleared → Auth.js cookie cleared → user sees logged-out page
- [ ]
post_logout_redirect_urimatches FusionAuth “Logout URL” exactly - [ ] After logout, visiting
/dashboardredirects to/auth - [ ] Invalid
id_token_hinton logout → graceful fallback to local session cleanup
Role-Based Access
- [ ] Admin user navigates to
/admin→ access granted - [ ] Non-admin user navigates to
/admin→ redirected to/dashboard - [ ] Role change in database → reflected on next login
Edge Cases
- [ ] User’s email domain changes in Entra → still accessible via
fusionAuthId/oid - [ ] FusionAuth session expires but Auth.js cookie is still valid → next request triggers re-auth
- [ ] IdP is down → Auth.js shows a meaningful error, not a blank page
- [ ] Two partners with the same email prefix but different domains → correctly routed to different IdPs
15. Production Pre-Flight
- [ ] FusionAuth Logout URL matches
${AUTH_URL}/auth/logoutexactly - [ ] FusionAuth redirect URL matches
${AUTH_URL}/api/auth/callback/fusionauthexactly - [ ]
AUTH_SECRETis a strong random value (32+ bytes), rotated quarterly - [ ]
AUTH_URLis set to the production domain (or verified to auto-detect correctly) - [ ] Database migration for
fusionAuthIdcolumn is deployed - [ ] Okta access policy is created and bound to the correct application
- [ ] Entra ID optional claims (
email,preferred_username) are configured - [ ] CORS origins in FusionAuth include the production domain
- [ ] TLS certificates are valid for
auth.pixieshop.com - [ ] Monitoring: track failed logins, logout errors,
fusionAuthIdlinking rate
16. Self-Verification Audit
This section lists every correction applied when consolidating the four source documents into this playbook. If you are reviewing this note, check each item to confirm the fix is present.
| # | Source File | Original Error | Fix Applied |
|---|---|---|---|
| 1 | fusionauth-sso-setup | Used NEXTAUTH_SECRET / NEXTAUTH_URL |
Changed to AUTH_SECRET / AUTH_URL (Auth.js v5) |
| 2 | fusionauth-sso-setup | Used callbackUrl in signIn() |
Changed to redirectTo (Auth.js v5) |
| 3 | fusionauth-sso-setup | Provider type set to "oauth" |
Changed to "oidc" (FusionAuth supports OIDC discovery) |
| 4 | fusionauth-sso-setup | Used FUSIONAUTH_CLIENT_ID / FUSIONAUTH_CLIENT_SECRET env vars |
Changed to AUTH_FUSIONAUTH_ID / AUTH_FUSIONAUTH_SECRET (v5 convention) |
| 5 | fusionauth-sso-setup | Company domain attend.tech in IdP routing |
Changed to toyworld.com (PixieShop scenario) |
| 6 | fusionauth-theme | FreeMarker variable ${applicationName} |
Corrected: this variable does not exist in OAuth authorize context. Use ${tenant.name} or hard-code |
| 7 | fusionauth-theme | FreeMarker variable ${action} in form action |
Corrected: use default form POST behavior or FreeMarker request helpers |
| 8 | fusionauth-theme | Pricing table listed “Essentials” as first paid tier | Corrected: current tiers are Community (free), Starter, Essentials, Enterprise |
| 9 | okta-oidc | Endpoints used /oauth2/v1/* (Org server) |
Corrected to /oauth2/default/v1/* (Custom Authorization Server) |
| 10 | okta-oidc | Navigated to “Azure Portal → Entra ID” | Corrected to entra.microsoft.com (modern admin center) |
| 11 | okta-oidc | import type { } = from "next" syntax error |
Corrected: removed erroneous = |
| 12 | okta-oidc | Username claim set to email for Okta |
Corrected to preferred_username (more reliable) |
| 13 | okta-oidc | Company name Attend throughout |
Changed to PixieShop / ToyWorld / EnchantedRetail |
| 14 | sso-nextauth | Used getServerSession(req, res, authOptions) (v4) |
Changed to auth() (Auth.js v5 universal function) |
| 15 | sso-nextauth | Used getToken({ req }) in middleware |
Removed: in v5, use req.auth directly in the auth middleware wrapper |
| 16 | sso-nextauth | Used callbackUrl parameter |
Changed to redirectTo |
| 17 | sso-nextauth | Used NEXTAUTH_SECRET / NEXTAUTH_URL env vars |
Changed to AUTH_SECRET / AUTH_URL |
| 18 | sso-nextauth | Used jwt_decode default import |
Corrected: jwt-decode v4 uses named export { jwtDecode }. But we removed client-side token decoding entirely — roles come from the profile in the jwt callback |
| 19 | sso-nextauth | Used fetch() + res.redirect() for federated logout |
Replaced with Next.js redirect() in server actions (App Router pattern) |
| 20 | sso-nextauth | Cookie name next-auth.session-token |
Corrected to authjs.session-token (Auth.js v5) |
| 21 | sso-nextauth | Pages Router API route pattern (pages/api/auth/...) |
Updated to App Router pattern (app/api/auth/[...nextauth]/route.ts) |
| 22 | okta-oidc | No mention of Okta Integrator Free Plan | Added: Developer Edition retired July 2025, replaced by Integrator Free Plan |
| 23 | okta-oidc | No mention of Entra email claim June 2023 change | Added: email claim breaking change, use oid + tid for authorization |
| 24 | fusionauth-sso-setup | Provider import from "next-auth/providers/fusionauth" |
Changed to inline object definition with type: "oidc" and issuer (Auth.js v5 generic OIDC) |
| 25 | sso-nextauth | export { handler as GET, handler as POST } from Pages Router file |
Updated to App Router export const { GET, POST } = handlers pattern |
| 26 | all files | Company name “Attend” / domain “attend.tech” throughout | Replaced with PixieShop / ToyWorld / EnchantedRetail fictional scenario |
| 27 | all files | type: rampup / company: Attend in frontmatter |
Removed company-specific metadata |
| 28 | fusionauth-theme | Described ${error_code} in error template |
Corrected: the actual FreeMarker variable is ${errorCode!''} (camelCase) |
| 29 | sso-nextauth | idp_hint described without noting it is FusionAuth-proprietary |
Added explicit note that idp_hint is not a standard OIDC parameter |
| 30 | okta-oidc | Entra secret instructions say “copy Value (not the ID)” but no explanation | Added: the Value is only shown once at creation time; if you miss it, create a new secret |