Right now TaskFlow logs in and out correctly, but it has a real usability problem: Okta's access and ID tokens are short-lived (an hour, by default), and once one expires, our Middleware from Phase 2 just kicks the user back to /login — even though they never actually "logged out." This phase fixes that properly, and also lets users see and update their own profile.
What Each Token Is Actually For — A Precise Recap
We touched on this back in Lecture 2, but now that you've handled these tokens in real code, the distinctions matter more:
- ID Token — A signed statement of identity, valid for a short window (default: 1 hour on Okta). Meant to be read by your app, never sent to an external API. We use it to know who's logged in.
- Access Token — A signed permission slip, also short-lived (default: 1 hour). Meant to be sent to APIs (Okta's own APIs, or your own protected Next.js Route Handlers, which we'll build in Phase 5) as proof of what the bearer can do.
- Refresh Token — A separate, much longer-lived credential (Okta's default for confidential web apps like ours is persistent, not single-use, unlike Single-Page Apps which get rotating short-lived refresh tokens). Its only job is to be exchanged for a fresh Access Token and ID Token, without forcing the user to log in again.
Short-lived access/ID tokens exist for a good reason: if one ever leaks, the damage window is small. The Refresh Token compensates for that short lifespan by quietly getting new tokens in the background — this is called silent renewal, and it's what we build next.
Step 1: Actually Requesting a Refresh Token
Two things must both be true for Okta to hand out a refresh token, and we haven't done either yet:
- Your Okta Application must have Refresh Token enabled as an allowed grant type.
- Your
/authorizerequest must include theoffline_accessscope — this is the specific scope that tells Okta "I want a refresh token too," and per Okta's rules it must be requested here, at authorization time, not later at token exchange time.
Go back to the Okta Admin Console → Applications → Applications → your TaskFlow app → General tab → Edit (in the Grant type section) and check Refresh Token alongside the existing Authorization Code grant. Save.
Now update src/app/api/auth/login/route.ts from Phase 2 — change one line:
authorizeUrl.searchParams.set("scope", "openid profile email offline_access");
And update the callback route's cookie-setting section in src/app/api/auth/callback/route.ts to also store the refresh token:
if (tokens.refresh_token) { response.cookies.set("refresh_token", tokens.refresh_token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 60 * 60 * 24 * 30, // Okta's default persistent refresh token lifetime is generous; 30 days is a reasonable cookie ceiling path: "/", }); }
Step 2: A Refresh Helper
Create src/lib/refreshTokens.ts:
interface RefreshedTokens { access_token: string; id_token: string; refresh_token?: string; expires_in: number; }
export async function refreshTokens(refreshToken: string): Promise<RefreshedTokens | null> { const response = await fetch(`${process.env.OKTA_ORG_URL}/oauth2/default/v1/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", client_id: process.env.OKTA_CLIENT_ID!, client_secret: process.env.OKTA_CLIENT_SECRET!, refresh_token: refreshToken, scope: "openid profile email offline_access", }), });
if (!response.ok) return null; return response.json(); }
This does exactly what its name says: takes a refresh token, sends the refresh_token grant to Okta's /token endpoint (the same endpoint we used for the initial exchange in Phase 2, just a different grant_type), and returns a fresh set of tokens.
Step 3: Wiring Silent Renewal Into Middleware
Now update src/middleware.ts from Phase 2 so that an expired ID token triggers a refresh attempt before giving up:
import { NextRequest, NextResponse } from "next/server"; import { verifyIdToken } from "@/lib/verifyToken"; import { refreshTokens } from "@/lib/refreshTokens";
export async function middleware(request: NextRequest) { const idToken = request.cookies.get("id_token")?.value; const refreshToken = request.cookies.get("refresh_token")?.value;
if (idToken) { try { await verifyIdToken(idToken); return NextResponse.next(); // Still valid, nothing to do. } catch { // Fall through to the refresh attempt below. } }
if (refreshToken) { const refreshed = await refreshTokens(refreshToken); if (refreshed) { const response = NextResponse.next(); response.cookies.set("id_token", refreshed.id_token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: refreshed.expires_in, path: "/", }); response.cookies.set("access_token", refreshed.access_token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: refreshed.expires_in, path: "/", }); if (refreshed.refresh_token) { response.cookies.set("refresh_token", refreshed.refresh_token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 60 * 60 * 24 * 30, path: "/", }); } return response; } }
// No valid session and no working refresh token — genuinely logged out. const response = NextResponse.redirect(new URL("/login", request.url)); response.cookies.delete("id_token"); response.cookies.delete("access_token"); response.cookies.delete("refresh_token"); return response; }
export const config = { matcher: ["/dashboard/:path*"], };
The logic now reads cleanly against what we just learned: try the existing ID token first; if it's expired or invalid, attempt a silent refresh using the refresh token; only redirect to /login if that refresh also fails (meaning the refresh token itself expired or was revoked — a genuine logout condition). Notice we conditionally update the refresh_token cookie too — this matters because if Okta ever rotates it, using the old one again would fail.
Step 4: Displaying the User's Profile
You already display name and email on the dashboard, pulled from ID token claims. For a fuller profile view, the standard OIDC way is the /userinfo endpoint, which returns whatever claims your granted scopes allow, keyed off the access token — useful because it reflects Okta's current stored profile, not a snapshot frozen at login time.
Create src/app/profile/page.tsx:
import { cookies } from "next/headers"; import { redirect } from "next/navigation";
async function getUserInfo(accessToken: string) { const response = await fetch(`${process.env.OKTA_ORG_URL}/oauth2/default/v1/userinfo`, { headers: { Authorization: `Bearer ${accessToken}` }, cache: "no-store", }); if (!response.ok) return null; return response.json(); }
export default async function ProfilePage() { const cookieStore = await cookies(); const accessToken = cookieStore.get("access_token")?.value;
if (!accessToken) redirect("/login");
const userInfo = await getUserInfo(accessToken);
return ( <div className="p-8 max-w-md"> <h1 className="text-2xl font-semibold text-slate-800 mb-4">Your Profile</h1> <div className="rounded-lg border border-slate-200 p-4 space-y-2"> <p><span className="text-slate-500">Name:</span> {userInfo?.name}</p> <p><span className="text-slate-500">Email:</span> {userInfo?.email}</p> <p><span className="text-slate-500">Locale:</span> {userInfo?.locale ?? "Not set"}</p> </div> </div> ); }
Add /profile to the Middleware matcher array from Step 3 so this page is protected too.
Step 5: Letting Users Update Their Own Profile
Older Okta tutorials handle this by calling the full Users Management API with an admin API token — that approach requires a privileged secret your app really shouldn't hold just to let a user edit their own name. Okta's current, correct answer for this exact case is the MyAccount API: end-user-scoped endpoints that work directly off the user's own access token, no admin credentials involved.
To enable it: in the Admin Console, go to your default Authorization Server → API Scopes tab, and grant the okta.myAccount.profile.manage scope (alongside the standard okta.myAccount.profile.read for reading). Then request that scope in your login route's scope parameter alongside the others.
A minimal update handler, src/app/api/profile/update/route.ts:
import { NextRequest, NextResponse } from "next/server";
export async function PATCH(request: NextRequest) { const accessToken = request.cookies.get("access_token")?.value; if (!accessToken) { return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); }
const body = await request.json(); // e.g. { firstName: "Aditi" }
const response = await fetch(`${process.env.OKTA_ORG_URL}/idp/myaccount/profile`, { method: "PATCH", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", Accept: "application/json; okta-version=1.0.0", }, body: JSON.stringify({ profile: body }), });
if (!response.ok) { return NextResponse.json({ error: "Update failed" }, { status: response.status }); }
return NextResponse.json(await response.json()); }
Notice the Accept header includes okta-version=1.0.0 — the MyAccount API requires an explicit API version in that header, which is a detail specific to this API and easy to miss.
A Note on Custom Attributes
Everything above works for Okta's built-in profile fields (firstName, lastName, email, and so on). If TaskFlow needs its own fields — say, a jobTitle or teamSize — you add them as custom attributes on the User profile schema, under Directory → Profile Editor → User (default) in the Admin Console. Once added there, they behave exactly like built-in fields for both /userinfo (if you map them to a claim — covered properly in Phase 5) and the MyAccount API.
Where TaskFlow Stands Now
Sessions survive past the one-hour token expiry through silent, automatic renewal. Users can see their live profile data and update it themselves through Okta's current, properly-scoped end-user API — no admin secrets involved anywhere in this flow.
That completes Phase 3.