Okta OIDC: choose the right auth server, scopes, and token contents
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 what data belongs in an ID token versus an access token. You’ll leave with a concrete request flow, practical config examples, and decision rules that prevent token misuse.
TL;DR — In Okta OIDC, the first decision is not "which SDK?" but "which authorization server issues the token, and who is supposed to read it?" Put user-facing identity data in the ID token only when the client needs it, put API authorization data in the access token only when the API needs it, and do not build resource-server logic around ID tokens. Reading time: ~7 min
What it is and where it sits
In Okta, OIDC token issuance happens behind an authorization server. That server defines the issuer (iss), signing keys, token endpoint, claims behavior, and which scopes can be requested. If you get this wrong, everything downstream gets weird: your SPA validates against the wrong issuer, your API rejects tokens with invalid_token, or your frontend starts parsing access tokens because the ID token is missing something it should have requested explicitly.
Architecture-wise, the authorization server sits between your client app and your APIs. It replaces older patterns like app-local sessions as the only source of identity, custom login forms that mint homegrown JWTs, or APIs trusting arbitrary bearer tokens without issuer/audience checks.
Typical flow:
Browser / Mobile App
|
| 1. /authorize?client_id=...&scope=openid profile api.read
v
Okta Authorization Server
|
| 2. user authenticates, consent/policy evaluated
|
| 3. redirect with code
v
Client App / Backend for Frontend
|
| 4. POST /token with code + PKCE/verifier
v
Okta Authorization Server
|
| 5. returns id_token + access_token (+ refresh_token if allowed)
v
Client App
|
|-- uses ID token locally for signed-in user context
|
`-- sends access token to API: Authorization: Bearer ...
|
| 6. API validates iss/aud/exp/sig/scopes
v
Resource API
The practical split:
- The client reads the ID token.
- The API reads the access token.
- Okta issues both, but they are for different audiences and should not be treated as interchangeable.
In Okta terms, you will usually encounter at least two issuer shapes:
https://{yourOktaDomain}
https://{yourOktaDomain}/oauth2/{authorizationServerId}
Those are not cosmetic differences. Different issuer means different metadata, different JWKS, and often different token semantics. If your API validates iss=https://example.okta.com/oauth2/default and your client obtained a token from https://example.okta.com, validation should fail.
How it actually works
Walk one realistic example: a React SPA calls a Go API. The SPA needs the signed-in user’s name for the header UI. The API needs to know whether the caller has orders.read.
Step 1: the client starts authorization against a specific issuer
The SPA redirects the browser to the authorization endpoint for one authorization server, not a generic "Okta login" URL. It requests only the scopes it needs:
openid profile orders.read
openidis what causes OIDC behavior and allows an ID token.profileasks for standard identity claims likename.orders.readis for the API authorization decision.
A typical authorize request shape:
curl -i "https://example.okta.com/oauth2/default/v1/authorize?client_id=0oa123abcXYZ&response_type=code&scope=openid%20profile%20orders.read&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&state=af0ifjsldkj&nonce=n-0S6_WzA2Mj&code_challenge_method=S256&code_challenge=ZWDJxD0k8kP7QJ9V8mY8Qz0Qx3c2nq0M4QxY8Qm0abc"```
If the redirect URI is wrong, the failure is usually immediate and boringly specific. Expect an HTTP 400, not a mysterious login loop:
```http
HTTP/2 400
content-type: text/html;charset=UTF-8
cache-control: no-cache, no-store
<html>...The 'redirect_uri' parameter must be a Login redirect URI in the client app settings...</html>
That error means: go to your identity provider dashboard, open the app integration for this client, and add the exact callback URL including scheme, host, port, and path. http://localhost:3000/callback and http://127.0.0.1:3000/callback are different.
Step 2: exchange the code for tokens
Your backend-for-frontend or SPA token helper posts to /token with the PKCE verifier.
Example response shape:
{
"token_type": "Bearer",
"expires_in": 3600,
"access_token": "eyJraWQiOiJ...",
"id_token": "eyJraWQiOiJ...",
"scope": "openid profile orders.read"
}
Now the important part: these two JWTs are not copies with different names.
Step 3: the client uses the ID token for sign-in context
The SPA can decode and validate the ID token and read claims like:
{
"iss": "https://example.okta.com/oauth2/default",
"aud": "0oa123abcXYZ",
"sub": "00u456defUVW",
"name": "Ava Chen",
"preferred_username": "ava@example.com",
"nonce": "n-0S6_WzA2Mj",
"exp": 1790850000
}
This is appropriate for UI context: show the user’s display name, correlate local session to sub, maybe show email if your privacy model allows it.
What should not happen: the SPA forwards this ID token to the API and expects the API to authorize based on it. The API is not the audience for this token. If your API accepts it, you have probably skipped proper aud validation.
Step 4: the API validates the access token and checks scopes
The SPA sends the access token:
Authorization: Bearer eyJraWQiOiJ...
The API validates:
- signature against the issuer’s JWKS
issexactly matches the configured issueraudmatches the API audience it expectsexpand optionallynbf- required scope, here
orders.read
A healthy access token payload often looks more API-centric than identity-centric:
{
"iss": "https://example.okta.com/oauth2/default",
"aud": "api://orders",
"sub": "00u456defUVW",
"scp": ["orders.read"],
"cid": "0oa123abcXYZ",
"exp": 1790850000
}
The API should decide authorization from access-token claims and server-side policy, not from whatever user profile fields happened to be placed in an ID token.
If issuer or audience is wrong, a decent resource server will emit errors like:
401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The issuer 'https://example.okta.com' is invalid"
or:
401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Audience validation failed"
Those messages usually mean you mixed up authorization servers, not that the token is malformed.
When to use it (and when not to)
Use authorization servers, scopes, and token separation when you have a real client/API boundary and need stable validation rules. Do not overcomplicate a simple server-rendered app that never calls a separate API.
| Scenario | Recommendation |
|---|---|
| SPA or mobile app calling your API | Use OIDC with Authorization Code + PKCE; ID token for client sign-in state, access token for API |
| Server-rendered web app with no separate API | You may not need to inspect tokens in app code at all; a session after OIDC login is often enough |
| Multiple APIs with different permissions | Use access-token scopes/audience per API; avoid shoving all authorization into one giant token |
| Frontend wants user name/email for display | Request profile/email; read from ID token or userinfo, not from access token unless your API specifically needs it |
| API needs roles/groups | Prefer access-token claims only if the API enforces them; otherwise fetch authorization data server-side |
| Internal app using ID token as API bearer token because "it already has the user info" | Don’t; this is the classic misuse |
| You only need authentication, not delegated API access | You probably don’t need custom API scopes at all |
You probably do not need custom scope design if your app is a single web server using OIDC only to log users in and then relying on a server-side session cookie. In that case, adding API-style scopes and token parsing in every request is usually ceremony.
Trade-offs
Every benefit here costs something.
-
Benefit: clean separation between client identity and API authorization.
Cost: more moving parts: issuer config, JWKS caching, audience checks, scope design. -
Benefit: APIs can validate bearer tokens offline using JWKS.
Cost: key rotation handling, cache invalidation bugs, and occasional 401 spikes during bad deployments. -
Benefit: scopes give you explicit least-privilege contracts.
Cost: someone has to govern scope sprawl.read,write,admin,orders.read,orders.manage,orders.exportbecomes taxonomy work fast. -
Benefit: ID token can carry enough profile data to avoid an extra round trip.
Cost: token bloat, privacy leakage into browser storage/logs, and pressure to put authorization data into the wrong token. -
Benefit: one provider handles login and token issuance consistently.
Cost: provider-specific operational knowledge and migration friction if you later change issuers or token formats. -
Benefit: access tokens can be tailored per API audience.
Cost: clients calling multiple APIs may need multiple token acquisition paths or careful audience strategy.
A practical rule: if a claim is only useful to render the signed-in user in the client, prefer ID token or userinfo. If a claim is required for API authorization, put it in the access token or derive it server-side.
In practice
Example 1: inspect the issuer metadata and confirm you’re using the right authorization server
ISSUER="https://example.okta.com/oauth2/default"
curl -s "$ISSUER/.well-known/openid-configuration" | jq '{issuer, authorization_endpoint, token_endpoint, jwks_uri, scopes_supported}'
This tells you exactly which endpoints and signing keys belong to the issuer your app is configured for. Gotcha: if your app config says https://example.okta.com but your API validates https://example.okta.com/oauth2/default, both may look plausible and still never interoperate.
Typical output shape:
{
"issuer": "https://example.okta.com/oauth2/default",
"authorization_endpoint": "https://example.okta.com/oauth2/default/v1/authorize",
"token_endpoint": "https://example.okta.com/oauth2/default/v1/token",
"jwks_uri": "https://example.okta.com/oauth2/default/v1/keys",
"scopes_supported": [
"openid",
"profile",
"email",
"address",
"phone",
"offline_access",
"orders.read"
]
}
Example 2: Node/Express API validating access tokens and enforcing scope
import express from "express";
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";
const app = express();
const issuer = "https://example.okta.com/oauth2/default";
const audience = "api://orders";
const client = jwksClient({
jwksUri: `${issuer}/v1/keys`,
cache: true,
cacheMaxEntries: 5,
cacheMaxAge: 10 * 60 * 1000
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
callback(null, key.getPublicKey());
});
}
function requireScope(required) {
return (req, res, next) => {
const auth = req.headers.authorization || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
if (!token) return res.status(401).set("WWW-Authenticate", 'Bearer error="invalid_token", error_description="Missing bearer token"').end();
jwt.verify(token, getKey, { algorithms: ["RS256"], issuer, audience }, (err, payload) => {
if (err) {
return res.status(401).set("WWW-Authenticate", `Bearer error="invalid_token", error_description="${err.message}"`).end();
}
const scopes = payload.scp || [];
if (!scopes.includes(required)) {
return res.status(403).json({ error: "insufficient_scope", required });
}
req.auth = payload;
next();
});
};
}
app.get("/orders", requireScope("orders.read"), (req, res) => {
res.json({ subject: req.auth.sub, scopes: req.auth.scp, orders: [] });
});
app.listen(8080);
This validates the access token against the issuer’s JWKS and rejects missing scope with 403. Gotcha: do not point this middleware at an ID token just because it is also a JWT; audience validation should fail if you configured it correctly.
Example 3: authorization request with explicit scopes and PKCE
CLIENT_ID="0oa123abcXYZ"
REDIRECT_URI="http://localhost:3000/callback"
ISSUER="https://example.okta.com/oauth2/default"
STATE=$(openssl rand -hex 16)
NONCE=$(openssl rand -hex 16)
VERIFIER=$(openssl rand -hex 32)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 | openssl base64 -A | tr '+/' '-_' | tr -d '=')
echo "Open this URL in a browser:"
echo "$ISSUER/v1/authorize?client_id=$CLIENT_ID&response_type=code&scope=openid%20profile%20orders.read&redirect_uri=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$REDIRECT_URI")&state=$STATE&nonce=$NONCE&code_challenge_method=S256&code_challenge=$CHALLENGE"
This generates a standards-compliant PKCE challenge for a manual test. Gotcha: if you request orders.read and the authorization server/client policy does not allow it, the error comes back at authorize or token time; inspect the exact response instead of assuming the SDK is broken.
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
- Okta docs: Authorization servers, OIDC app integrations, and token validation
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