JWT + Okta Auth Integration: A Short Practical Guide for Frontend and Backend Developers
Research date: Aug 16, 2026. Every factual claim is cited inline to an authoritative source (IETF RFCs or official Okta docs). This is a practical "one screen per concept" explainer for the two people who actually wire this up: the frontend/web-app developer and the backend REST API developer.
TL;DR
- JWT (RFC 7519) is a signed, not encrypted token. Anyone can decode the payload; only the signature proves who issued it. Verify the signature and never put secrets in it.
- OAuth 2.0 / OIDC is how you obtain tokens. The access token authorizes API calls; the ID token proves who the user is. They are not interchangeable.
- Okta is the identity provider / authorization server. It runs the OIDC endpoints, signs the tokens with RS256, and publishes its public keys in a JWKS so your backend can verify tokens locally.
- Frontend job: do the login redirect dance (Authorization Code + PKCE), keep tokens in memory, send
Authorization: Bearer <access_token>. - Backend job: verify every access token against Okta's JWKS (signature +
iss/aud/exp/iat), treat it as stateless, and never ship a refresh token to the API.
1. JWT in one screen
A JWT is "a compact, URL-safe means of representing claims to be transferred between two parties," where claims are a signed/encrypted JSON object (https://www.rfc-editor.org/rfc/rfc7519.html). It looks like header.payload.signature — three base64url segments joined by dots (RFC 7519 §3.1):
eyJhbGciOiJSUzI1NiIsImtpZCI6InNrLTEifQ.eyJpc3MiOiJodHRwczovL3lv...
| Part | Contents | Example |
|---|---|---|
| Header | signing algorithm + key id | {"alg":"RS256","kid":"sk-1"} |
| Payload | the claims (JSON) | {"iss":"...","sub":"user-123","aud":"my-api","exp":...} |
| Signature | crypto over header.payload |
Base64Url(RS256(header.payload, privKey)) |
HS256 vs RS256 (RFC 7518 §3.2, §3.3):
| HS256 (HMAC-SHA256) | RS256 (RSA-SHA256) | |
|---|---|---|
| Key type | one shared symmetric secret | asymmetric public/private key pair |
| Who holds the secret | both signer and verifier | signer keeps private; verifiers get public |
| Use case | same trusted service signs and verifies | identity provider signs, many resource servers verify |
| Okta's choice | — | RS256 (https://developer.okta.com/docs/guides/validate-access-tokens/main/) |
Claims that matter (RFC 7519 §4.1):
| Claim | Meaning | Check |
|---|---|---|
iss |
issuer — who signed this | must match your identity provider |
sub |
subject — the user | use as the user id |
aud |
audience — who it's for | must contain your API's audience |
exp |
expiration (NumericDate) | must be in the future |
iat |
issued-at time | sanity check |
nbf |
not-before | reject if before this (rarely used) |
jti |
unique token id | replay/revocation bookkeeping |
Why stateless: the token self-contains the authorization info in a verifiable form, so a resource server can validate it without contacting the issuer on every request (RFC 6749 §1.4: a token "may self-contain the authorization information in a verifiable manner (i.e., a token string consisting of some data and a signature)"). That's the whole point — local verification, no DB or network lookup per call.
The caveats (read these twice):
- Signed ≠ encrypted. The payload is just base64url JSON — anyone can decode it. RFC 7519 §12: if a JWT may carry privacy-sensitive info, measures MUST be taken to prevent disclosure; the simplest is to leave sensitive info out. Never put secrets (passwords, API keys, tokens) in the payload.
- Verify the signature before trusting anything. RFC 7519 §11.1: contents "cannot be relied upon in a trust decision unless its contents have been cryptographically secured." RFC 8725 §3.10 ("Do Not Trust Received Claims") — a JWT is a claim by the issuer, not a fact.
- Algorithm confusion is a real attack. Attackers have swapped
RS256→HS256and tricked libraries into verifying an RSA-signed token as an HMAC with the public key as the HMAC secret, and flippedalgtonone(RFC 8725 §2.1, https://www.rfc-editor.org/rfc/rfc8725.html). Mitigation: pin the allowed algorithms (RFC 8725 §3.1) and reject anything else.
2. OAuth 2.0 / OIDC in one screen
OAuth 2.0 defines four roles (RFC 6749 §1.1):
| Role | Who it is in your stack |
|---|---|
| Resource owner | the end user |
| Client | your web app / SPA |
| Authorization server | the identity provider — Okta |
| Resource server | your backend REST API |
OIDC (OpenID Connect) extends OAuth 2.0 with authentication — it standardizes user sign-in and adds the ID token, a JWT about the authentication event and the user (https://developer.okta.com/docs/concepts/oauth-openid/).
ID token vs access token — never confuse them:
| ID token | Access token | |
|---|---|---|
| Purpose | prove who the user is (authentication) | authorize access to an API (authorization) |
aud |
your app's client ID | your API's audience |
| Who uses it | the client app (frontend) | the resource server (backend) |
| Okta guidance | "use only the access token to grant access, and not the ID token" (https://developer.okta.com/docs/guides/validate-access-tokens/main/) | backend authorizes requests from this |
The flow you want for a browser app: Authorization Code + PKCE.
- Frontend redirects the browser to Okta's
/authorizeendpoint withresponse_type=code+ a PKCEcode_challenge. - User authenticates on Okta-hosted pages.
- Okta redirects back with an authorization code.
- Frontend (or SDK) exchanges the code +
code_verifierat/tokenfor access/ID/refresh tokens.
Why this flow: the implicit grant (tokens issued straight into the browser redirect) is deprecated by current best practice — RFC 9700 §2.1.2 says clients "SHOULD NOT use the implicit grant" because access tokens leak into URLs, browser history, and Referer headers; use response_type=code instead (https://www.rfc-editor.org/rfc/rfc9700.html). Public clients MUST use PKCE (RFC 9700 §2.1.1, and RFC 7636 defines PKCE). PKCE + a fresh state/nonce also covers the login-flow CSRF risk (RFC 9700 §2.1). Okta recommends Authorization Code + PKCE for SPAs, web apps, and native apps (https://developer.okta.com/docs/concepts/oauth-openid/).
3. Okta in one screen
What Okta is: the identity provider — an OAuth 2.0 authorization server and OIDC provider ("Okta is your authorization server"). Each authorization server has a unique issuer URI and its own signing key, keeping security domains apart (https://developer.okta.com/docs/concepts/oauth-openid/).
What Okta issues:
- Access token — a JWT with header
{"alg":"RS256","kid":"..."}and payload claims incl.iss,aud,sub,iat,exp,scp(scopes),cid,uid(https://developer.okta.com/docs/reference/api/oidc/). - ID token — a JWT with the user's profile claims;
aud= your client ID. - Refresh token — opaque; exchanged at
/tokenfor new access tokens (https://developer.okta.com/docs/reference/api/oidc/).
Endpoints (paths are relative to your issuer, e.g. https://{yourOktaDomain}/oauth2/default):
| Endpoint | Purpose |
|---|---|
/.well-known/oauth-authorization-server |
OIDC discovery document — lists everything below incl. jwks_uri (https://developer.okta.com/docs/guides/validate-access-tokens/main/) |
/v1/authorize |
starts login, returns the code |
/v1/token |
code → tokens; refresh tokens; revocation |
/v1/userinfo |
get the user's profile with the access token |
/v1/keys |
JWKS — Okta's public signing keys for signature verification (https://developer.okta.com/docs/reference/api/oidc/) |
Setup in the Okta dashboard (5 minutes):
- Admin Console → Applications → Create App Integration → OIDC - OpenID Connect.
- Pick Single-Page Application (or Web). Note: "If you choose an inappropriate app type, it can break the sign-in or sign-out flows" — public clients have no client secret (https://developer.okta.com/docs/guides/sign-into-spa-redirect/angular/main/).
- Grant types: Authorization Code and Refresh Token (this enables Authorization Code + PKCE and token refresh). Refresh-token rotation is the default for SPAs.
- Register Sign-in redirect URIs (e.g.
http://localhost:4200/login/callback) and sign-out redirect URIs. - Copy Client ID (General tab) and Issuer (Security → API → Authorization Servers → Issuer URI).
- Add your app origin under Security → API → Trusted Origins to allow CORS/Okta API access (https://developer.okta.com/docs/guides/sign-into-spa-redirect/angular/main/).
Token lifetimes to know: on the org authorization server, access & ID tokens are hard-coded to 60 minutes and refresh tokens to 90 days; on a custom authorization server, access tokens are configurable between 5 minutes and 24 hours (https://developer.okta.com/docs/reference/api/oidc/).
4. Web app developer (frontend): integrate Okta login
Use an SDK. Okta ships @okta/okta-auth-js plus framework wrappers (@okta/okta-angular, etc.) and the hosted Sign-In Widget. The redirect model — frontend delegates the whole sign-in UI to Okta-hosted pages — is the recommended, stronger default (https://developer.okta.com/docs/concepts/oauth-openid/).
Configure with the three values from Step 3:
const oktaAuth = new OktaAuth({
issuer: 'https://{yourOktaDomain}/oauth2/default',
clientId: '{yourClientId}',
redirectUri: window.location.origin + '/login/callback',
scopes: ['openid', 'profile', 'offline_access'] // offline_access → refresh token
});
The flow you implement:
- User clicks sign in →
await oktaAuth.signInWithRedirect()→ browser goes to Okta's/authorize. The SDK handles Authorization Code + PKCE by default (pkce: trueis the default in@okta/okta-auth-js) and generates a freshstatefor CSRF protection (https://github.com/okta/okta-auth-js#pkce-oauth-20-flow). - Okta redirects back to your
/login/callbackroute → SDK exchanges the code for tokens. - Attach the access token to API calls:
Authorization: Bearer <access_token>. Okta's own sample interceptor only adds the header for your allowed origins — good hygiene, since your SPA will also load third-party assets (https://developer.okta.com/docs/guides/sign-into-spa-redirect/angular/main/).
// minimal interceptor sketch (Angular/React analogous)
fetch('/api/me', {
headers: { 'Authorization': `Bearer ${oktaAuth.getAccessToken()}` }
});
Where to keep tokens — this is a security decision:
- A browser cannot keep a true secret. Okta's guidance is blunt: "Long-lived refresh tokens aren't suitable for clients such as single-page apps (SPAs)... there isn't a way to safely store persistent refresh tokens in a browser" (https://developer.okta.com/docs/guides/refresh-tokens/main/).
- So for a SPA: keep the access token in memory (not
localStorage).@okta/okta-auth-jssupports storage typesmemory,sessionStorage,localStorage, andcookie—memorysurvives no page reload but exposes nothing to XSS, whereas anything inlocalStorageis readable by any script running on your origin (https://github.com/okta/okta-auth-js#storagetypes). - Combine short-lived access tokens with refresh token rotation (the default for Okta SPAs) instead of a persistent refresh token: each refresh returns a new refresh token and Okta detects reuse of an old one, revoking everything issued since authentication (https://developer.okta.com/docs/guides/refresh-tokens/main/).
- Don't
console.logtokens, don't put them in query strings, and clear them on sign-out (oktaAuth.signOut()).
5. Backend REST API developer: verify the JWT on every request
Your API is the resource server. You never see the user's password, never run the OAuth dance, and never handle refresh tokens. You verify the access token in the Authorization: Bearer header.
The verification checklist (per Okta's "Validate Access Tokens" guide, https://developer.okta.com/docs/guides/validate-access-tokens/main/):
- Fetch and cache the JWKS from Okta's discovery document (
issuer + "/.well-known/oauth-authorization-server", thenjwks_uri). Cache it and re-fetch on a schedule — Okta rotates signing keys regularly (https://developer.okta.com/docs/reference/api/oidc/). - Verify the signature against the JWK selected by the token's
kid. Okta signs with RS256. - Verify
issequals your Okta issuer URL. - Verify
audequals the audience you configured for this API in the Okta authorization server (for a custom AS this is e.g.api://default). - Verify
exp(andiat/nbf) — reject expired tokens; allow small clock skew (Okta recommends ≤ 2 minutes). - Pin
alg= RS256 and reject anything else (RFC 8725 §3.1). - Use the verified claims (
sub,scp) for identity and authorization — don't trust unverified token bytes.
Middleware pattern (pseudocode, language-agnostic):
// Runs before every protected route — never on a per-request AS call.
async function authMiddleware(req, res, next) {
const jwt = req.headers.authorization?.replace(/^Bearer /, '');
const keys = await jwksCache.get(); // cached JWKS from Okta
const payload = verifyJwt(jwt, { keys }); // alg pin + signature
if (payload && payload.iss === ISSUER // Okta issuer URL
&& payload.aud.includes(API_AUD) // your API audience
&& payload.exp > now) { // lifetime
req.user = { sub: payload.sub, scopes: payload.scp };
return next();
}
return res.status(401).json({ error: 'invalid token' });
}
Okta publishes JWT-verification middleware for popular frameworks (e.g. the Okta.AspNetCore package configures your API's authentication against Okta and maps verified claims into the request principal — https://developer.okta.com/docs/guides/protect-your-api/main/). Use the official middleware for your stack rather than hand-rolling crypto.
Remote validation (optional): if you must guarantee a token hasn't been revoked, Okta also offers the /introspect endpoint — it's a network call, "slower," but confirms the token is still active (https://developer.okta.com/docs/guides/validate-access-tokens/main/). Use local JWT verification by default; introspection only where you need instant revocation.
Refresh tokens — your API's rules:
- The refresh token goes only to Okta's /token endpoint, never to your API. RFC 6749 §1.5: refresh tokens "are never sent to resource servers." If your API ever receives one, that's a client bug — reject it.
- Your API just returns 401 on an expired access token; the client refreshes via Okta. Okta's rotation default makes stolen refresh tokens self-destructing via reuse detection (https://developer.okta.com/docs/guides/refresh-tokens/main/).
- For logout / revocation: revoke the client's tokens at Okta (the OAuth 2.0 revocation endpoint, RFC 7009, is part of Okta's token endpoints) and end the Okta session so the ID/access/refresh set can't be re-minted. Okta handles single logout across your OIDC app when the user signs out.
CORS/CSRF notes:
- CORS: enable CORS on your API for the exact origins of your SPA, not *. Okta's API guide ships an "AllowAll" sample but warns to restrict it in production (https://developer.okta.com/docs/guides/protect-your-api/main/). Okta likewise wants Trusted Origins listed, not open CORS (https://developer.okta.com/docs/guides/sign-into-spa-redirect/angular/main/).
- CSRF: a bearer token in an Authorization header is not sent automatically by the browser, so a cross-site POST can't piggyback it — the classic CSRF risk lives in the login flow, which PKCE/state/nonce covers (RFC 9700 §2.1). Keep tokens in headers, never in cookies you're auto-sending, and you stay out of CSRF territory.
- Always use TLS/HTTPS everywhere — the token is only as safe as the channel (RFC 9700 §2.6).
6. Putting it together — checklist
Both roles, once:
- [ ] Create the OIDC app integration in Okta: client ID, issuer, redirect URIs, grant types = Authorization Code + Refresh Token (https://developer.okta.com/docs/guides/sign-into-spa-redirect/angular/main/).
- [ ] Configure the API's audience on the Okta authorization server (e.g. api://default) (https://developer.okta.com/docs/guides/protect-your-api/main/).
Frontend developer:
- [ ] Use @okta/okta-auth-js (+ framework wrapper) or the hosted Sign-In Widget.
- [ ] Authorization Code + PKCE flow (SDK default), fresh state per request.
- [ ] Keep access tokens in memory, not localStorage; rely on refresh token rotation, not persistent refresh tokens (https://developer.okta.com/docs/guides/refresh-tokens/main/).
- [ ] Send Authorization: Bearer <access_token> only to your allowed API origins.
- [ ] Sign-out clears tokens and ends the Okta session.
Backend developer:
- [ ] Verify every protected request: signature vs cached JWKS, alg=RS256, iss, aud, exp (https://developer.okta.com/docs/guides/validate-access-tokens/main/).
- [ ] Use Okta/official JWT middleware for your framework; don't hand-roll crypto.
- [ ] Authorize from verified claims (sub, scp) only; reject refresh tokens at the API.
- [ ] Return 401 on invalid/expired tokens; let the client refresh via Okta.
- [ ] CORS restricted to your known SPA origins; HTTPS everywhere.
Sources
- RFC 7519 — JSON Web Token: https://www.rfc-editor.org/rfc/rfc7519.html
- RFC 6749 — OAuth 2.0 Authorization Framework: https://www.rfc-editor.org/rfc/rfc6749.html
- RFC 7636 — Proof Key for Code Exchange (PKCE): https://www.rfc-editor.org/rfc/rfc7636.html
- RFC 8725 — JWT Best Current Practices: https://www.rfc-editor.org/rfc/rfc8725.html
- RFC 9700 — OAuth 2.0 Security Best Current Practice: https://www.rfc-editor.org/rfc/rfc9700.html
- Okta — OAuth 2.0 and OpenID Connect overview: https://developer.okta.com/docs/concepts/oauth-openid/
- Okta — Validate Access Tokens: https://developer.okta.com/docs/guides/validate-access-tokens/main/
- Okta — Refresh tokens and rotation: https://developer.okta.com/docs/guides/refresh-tokens/main/
- Okta — Sign users in to your SPA (redirect model): https://developer.okta.com/docs/guides/sign-into-spa-redirect/angular/main/
- Okta — Protect your API endpoints: https://developer.okta.com/docs/guides/protect-your-api/main/
- Okta — OpenID Connect & OAuth 2.0 API reference: https://developer.okta.com/docs/reference/api/oidc/
- Okta Auth JavaScript SDK (README): https://github.com/okta/okta-auth-js