Okta OIDC: choose the right auth server, scopes, and token claims
For developers wiring Okta into web apps and APIs, this guide explains the part that usually causes bad architecture: which authorization server to use, which scopes to request, and which claims belong in an ID token versus an access token. You’ll get a concrete end-to-end flow, practical config examples, and decision rules that prevent leaking user data into the wrong token.
TL;DR — In Okta OIDC, the biggest design mistake is treating all tokens as interchangeable. Use the authorization server that matches your audience, put client-facing identity data in the ID token only when the client needs it, and put API authorization data in the access token only when the resource server needs it. Reading time: ~7 min
What it is and where it sits
In Okta, OIDC is the layer that issues tokens to clients after user authentication. The part that matters architecturally is not “OIDC vs OAuth” in theory; it’s that Okta can issue tokens from different authorization servers, and those servers define the issuer, audience, scopes, claims, and policy behavior your app and APIs will trust.
In a typical setup:
- the browser or mobile app talks to your frontend
- your frontend redirects the user to Okta for login
- Okta returns an authorization code to the client
- the client exchanges that code for tokens
- the client uses the ID token to know who the user is
- the client sends the access token to your API
- your API validates the access token against the issuer and audience from the same authorization server
If you get the server wrong, validation fails even when the JWT “looks valid”. Typical symptom: your API rejects a token with an issuer or audience mismatch because the frontend got it from a different authorization server than the API expects.
Browser SPA/Web App
|
| 1. GET /login
v
Your app ---------------------------> Okta authorization endpoint
| |
| 2. redirect back with code | user authenticates
v v
Your app exchanges code -----------> Okta token endpoint
| |
| 3. gets ID token + access token |
v v
Uses ID token locally Sends access token to API
|
v
API validates JWT
using issuer/JWKS/audience
What it replaces: old patterns where the app handled passwords directly, or where a session cookie was the only artifact crossing every boundary. With OIDC, authentication is delegated to Okta, and APIs stop trusting browser session state directly.
The key boundary is this:
- ID token is for the client application.
- Access token is for the resource server API.
If your API is reading profile data from the ID token because “it was already there”, you have crossed the trust boundary incorrectly.
How it actually works
Walk one realistic flow: server-rendered web app plus backend API.
Scenario:
- web app:
https://app.example.com - API:
https://api.example.com - Okta issuer:
https://your-okta-domain/oauth2/default - client requests scopes:
openid profile email api.read
Step 1: Redirect to the authorization endpoint
Your app sends the user to Okta with PKCE or client authentication depending on app type. The request shape is what matters:
curl -i "https://your-okta-domain/oauth2/default/v1/authorize?client_id=abc123&response_type=code&scope=openid%20profile%20email%20api.read&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&state=s123&nonce=n123&code_challenge=Zwd5...&code_challenge_method=S256"
A browser gets a 302 to the login page if no session exists. If the redirect URI is not registered exactly, the user usually lands on an error page and your app never gets a code.
Typical diagnostic shape:
HTTP/2 400
content-type: text/html;charset=UTF-8
x-okta-request-id: YxExample
error=invalid_request
error_description=The 'redirect_uri' parameter must be a Login redirect URI in the client app settings.
This is not a token problem. Fix the app integration’s redirect URI list in your provider dashboard under the application’s sign-in or login redirect settings.
Step 2: Exchange the code for tokens
Confidential web app example:
curl -sS -u 'abc123:super-secret' \
-H 'content-type: application/x-www-form-urlencoded' \
--data 'grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback' \
https://your-okta-domain/oauth2/default/v1/token | jq
Response shape:
{
"token_type": "Bearer",
"expires_in": 3600,
"access_token": "eyJraWQiOi...",
"scope": "openid profile email api.read",
"id_token": "eyJraWQiOi..."
}
Now the separation matters.
Step 3: Use the ID token for the client
The web app validates the ID token:
issmust equalhttps://your-okta-domain/oauth2/defaultaudmust equal the client IDnoncemust match what the app stored before redirect- signature must verify against the issuer’s JWKS
What belongs here:
- stable user identity for the client:
sub - display/profile data the client needs now:
name,preferred_username,email - authentication context if the client needs it:
auth_time,amr
What does not belong here:
- API entitlements that only the API should evaluate
- large group lists unless you have a very specific client-side need and can tolerate token bloat
- secrets, internal IDs, or data you would not hand to the browser
Step 4: Use the access token for the API
The client calls the API:
curl -sS https://api.example.com/orders \
-H "authorization: Bearer $ACCESS_TOKEN"
The API validates:
- signature via JWKS from the same issuer
issexactly matches configured issueraudmatches the API audience your API expects- token not expired
- required scope present, for example
api.read
If the API sees scp: ["api.read"], it authorizes read access. If it needs group or role claims to enforce domain rules, those claims belong in the access token, because the API is the consumer.
Common failure when teams mix servers:
{
"error": "invalid_token",
"error_description": "The issuer 'https://your-okta-domain' does not match 'https://your-okta-domain/oauth2/default'"
}
That means your client got a token from one issuer and your API validates against another. Fix either the client’s authorize/token endpoints or the API’s issuer config; do not “disable issuer validation”.
When to use it (and when not to)
Use authorization servers and token claim design deliberately, not by copy-pasting whatever the sample app emitted.
| Scenario | Recommendation |
|---|---|
| Web app signs users in and only the app needs profile info | Request openid plus minimal identity scopes like profile or email; use the ID token in the app |
| SPA or mobile app calls your API | Request OIDC scopes for sign-in and API scopes for the API; send only the access token to the API |
| API needs roles/groups to authorize requests | Put those claims in the access token from the authorization server the API trusts |
| Frontend wants to show the user’s name/avatar | Put those in the ID token or fetch from the UserInfo endpoint; don’t parse the access token in the UI |
| You have no custom API and only need SSO into the app | You probably don’t need custom API scopes design beyond basic OIDC scopes |
| You want one token used everywhere because it’s simpler | Don’t; that usually creates audience confusion and over-shares claims |
| You need machine-to-machine access without a user | Use OAuth client credentials and access tokens; ID tokens are irrelevant |
You probably don’t need extra custom scopes if your API has one coarse permission model and can derive authorization from app-side session state. But once multiple APIs or independent resource servers exist, explicit API scopes become worth the ceremony.
Trade-offs
Every benefit here costs something.
-
Benefit: clear separation of identity and API authorization. Cost: more configuration surface: issuer, audience, scopes, claims, policies must line up across app and API.
-
Benefit: APIs can validate tokens locally with JWKS, reducing callback latency. Cost: you now own JWT validation correctness, key rotation handling, and clock-skew tolerance.
-
Benefit: least-privilege scopes let you constrain what clients can do. Cost: scope taxonomy becomes product design. Too many scopes create operational drag and client confusion.
-
Benefit: custom claims in access tokens can remove extra directory lookups. Cost: token size grows. Large headers break proxies, cookies, and some ingress defaults faster than teams expect.
-
Benefit: one identity provider centralizes login and policy. Cost: provider-specific configuration and claim mapping can create lock-in. Keep your app logic based on standard claims and standard OIDC/OAuth semantics where possible.
-
Benefit: putting only needed claims in each token reduces data exposure. Cost: sometimes the client needs another round trip to UserInfo or your own profile API instead of reading everything from one JWT.
In practice
Example 1: Discover the issuer and JWKS, then validate the token source
ISSUER="https://your-okta-domain/oauth2/default"
curl -sS "$ISSUER/.well-known/openid-configuration" | jq '{issuer, authorization_endpoint, token_endpoint, jwks_uri, userinfo_endpoint}'
This tells you which issuer your app is actually using and where the JWKS lives. Gotcha: if your API is configured with a different issuer string than this output, validation will fail even if the signing keys are otherwise reachable.
Example output shape:
{
"issuer": "https://your-okta-domain/oauth2/default",
"authorization_endpoint": "https://your-okta-domain/oauth2/default/v1/authorize",
"token_endpoint": "https://your-okta-domain/oauth2/default/v1/token",
"jwks_uri": "https://your-okta-domain/oauth2/default/v1/keys",
"userinfo_endpoint": "https://your-okta-domain/oauth2/default/v1/userinfo"
}
Example 2: API JWT validation config in Node/Express
import express from "express";
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";
const app = express();
const issuer = "https://your-okta-domain/oauth2/default";
const audience = "api://orders";
const client = jwksClient({
jwksUri: `${issuer}/v1/keys`,
cache: true,
cacheMaxEntries: 5,
cacheMaxAge: 10 * 60 * 1000
});
function getKey(header, cb) {
client.getSigningKey(header.kid, (err, key) => {
if (err) return cb(err);
cb(null, key.getPublicKey());
});
}
function requireScope(scope) {
return (req, res, next) => {
const auth = req.headers.authorization || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
if (!token) return res.status(401).json({ error: "missing_token" });
jwt.verify(token, getKey, { issuer, audience, algorithms: ["RS256"] }, (err, decoded) => {
if (err) return res.status(401).json({ error: "invalid_token", detail: err.message });
const scopes = decoded.scp || decoded.scope?.split(" ") || [];
if (!scopes.includes(scope)) return res.status(403).json({ error: "insufficient_scope" });
req.user = decoded;
next();
});
};
}
app.get("/orders", requireScope("api.read"), (req, res) => {
res.json({ sub: req.user.sub, orders: [] });
});
app.listen(3000);
This validates the access token against issuer, audience, signature, and scope. Gotcha: don’t point audience at your client ID unless your API is explicitly using that as its audience; most API validation bugs are really audience mismatches.
Example 3: Request only the claims the client needs
{
"authorizationParams": {
"response_type": "code",
"scope": "openid profile email api.read",
"audience": "api://orders"
}
}
This is the right intent: OIDC scopes for sign-in plus one API scope for the backend. Gotcha: if the frontend starts depending on role/group claims that exist only in the access token, someone will eventually parse the access token in the browser and couple UI logic to API authorization internals.
⚠️ If you change issuer, audience, or claim mappings in a live environment, expect immediate auth failures for some clients and APIs until every component is updated. Roll these changes with a compatibility window or a staged deployment, not as an in-place surprise.
Further reading
- OpenID Connect Core 1.0
- OAuth 2.0 Authorization Framework
- OAuth 2.0 for Browser-Based Apps
- JSON Web Token (JWT) RFC 7519
- OpenID Connect Discovery 1.0
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI