What Okta Tokens Look Like
Okta issues JWTs from two kinds of authorization servers: the Org Authorization Server (default behavior at /oauth2/v1/*) returns opaque access tokens that must be introspected, while Custom Authorization Servers(configured in the Okta admin console at Security > API) return proper JWT access tokens and ID tokens. If you're pasting a token into a JWT decoder and seeing JSON claims, you're working with a Custom Authorization Server. The default custom auth server lives at /oauth2/default/v1/*.
Standard Okta Token Claims
// Access token payload
{
"ver": 1,
"jti": "AT.0mP9j...",
"iss": "https://YOUR_DOMAIN.okta.com/oauth2/default",
"aud": "api://default",
"iat": 1735689600,
"exp": 1735693200, // 1h later
"cid": "0oa1k2j3l4m5n6o7p8", // the OAuth client that got the token
"uid": "00u1a2b3c4d5e6f7g", // Okta user ID
"scp": ["openid", "profile", "offline_access", "orders:read"],
"sub": "[email protected]",
"groups": ["admin", "beta-users"] // only if group claim configured
}
// ID token payload (short, OIDC standard claims)
{
"ver": 1,
"jti": "ID.XoP...",
"iss": "https://YOUR_DOMAIN.okta.com/oauth2/default",
"aud": "0oa1k2j3l4m5n6o7p8", // ID token audience is clientId
"iat": 1735689600,
"exp": 1735693200,
"sub": "00u1a2b3c4d5e6f7g",
"email": "[email protected]",
"email_verified": true,
"name": "Ada Lovelace",
"preferred_username": "[email protected]"
}Method 1: Verify in Node.js with jose
import { createRemoteJWKSet, jwtVerify } from 'jose';
const OKTA_DOMAIN = 'https://YOUR_DOMAIN.okta.com';
const JWKS = createRemoteJWKSet(
new URL(`${OKTA_DOMAIN}/oauth2/default/v1/keys`)
);
async function verifyOktaToken(token) {
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
issuer: `${OKTA_DOMAIN}/oauth2/default`,
audience: 'api://default',
});
console.log('kid used:', protectedHeader.kid);
console.log('user:', payload.sub);
console.log('scopes:', payload.scp);
return payload;
}
// Express middleware
export async function requireOktaAuth(req, res, next) {
const auth = req.headers.authorization || '';
const token = auth.replace(/^Bearer /, '');
try {
req.user = await verifyOktaToken(token);
next();
} catch (err) {
if (err.code === 'ERR_JWT_EXPIRED') return res.status(401).json({ error: 'Expired' });
return res.status(401).json({ error: 'Invalid token' });
}
}
// Scope-based authorization
export function requireScope(scope) {
return (req, res, next) => {
if (!req.user?.scp?.includes(scope)) {
return res.status(403).json({ error: `Requires scope: ${scope}` });
}
next();
};
}
app.get('/orders', requireOktaAuth, requireScope('orders:read'), getOrders);Method 2: Verify in Python with python-jose
import requests
from functools import lru_cache
from jose import jwt
from jose.exceptions import JWTError, ExpiredSignatureError
OKTA_DOMAIN = "https://YOUR_DOMAIN.okta.com"
ISSUER = f"{OKTA_DOMAIN}/oauth2/default"
AUDIENCE = "api://default"
@lru_cache(maxsize=1)
def _jwks():
r = requests.get(f"{ISSUER}/v1/keys", timeout=5)
r.raise_for_status()
return r.json()
def verify_okta_token(token: str) -> dict:
header = jwt.get_unverified_header(token)
jwks = _jwks()
key = next((k for k in jwks["keys"] if k["kid"] == header["kid"]), None)
if not key:
_jwks.cache_clear() # key rotated — refetch
key = next((k for k in _jwks()["keys"] if k["kid"] == header["kid"]), None)
if not key:
raise JWTError("Unknown kid")
return jwt.decode(
token, key,
algorithms=["RS256"],
issuer=ISSUER,
audience=AUDIENCE,
)
# FastAPI dependency
from fastapi import Depends, HTTPException, Header
async def current_user(authorization: str = Header(...)) -> dict:
token = authorization.replace("Bearer ", "")
try:
return verify_okta_token(token)
except ExpiredSignatureError:
raise HTTPException(401, "Token expired")
except JWTError:
raise HTTPException(401, "Invalid token")Method 3: curl Walkthrough (Debugging)
# 1. Get a token (client credentials grant)
curl -X POST "$OKTA_DOMAIN/oauth2/default/v1/token" \
-H "Authorization: Basic $(echo -n "$CLIENT_ID:$CLIENT_SECRET" | base64)" \
-d "grant_type=client_credentials&scope=orders:read" | jq .
# 2. Inspect token claims (local)
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
# 3. Introspect (ask Okta if it's active)
curl -X POST "$OKTA_DOMAIN/oauth2/default/v1/introspect" \
-H "Authorization: Basic $(echo -n "$CLIENT_ID:$CLIENT_SECRET" | base64)" \
-d "token=$TOKEN&token_type_hint=access_token" | jq .
# { "active": true, "scope": "orders:read", "exp": ..., ... }
# 4. Call your API
curl "$API_URL/orders" -H "Authorization: Bearer $TOKEN"Common Okta JWT Pitfalls
- Using the Org Auth Server instead of Custom — Org server returns opaque tokens (not JWTs), so decoding fails. Use
/oauth2/default/or your custom server's path. - Wrong audience for ID tokens — ID token
audis your clientId, not your API audience. Access tokenaudis your API audience. - Hardcoding the public key — Okta rotates keys roughly every 90 days. Always fetch via JWKS and cache by kid with a short TTL.
- Trusting a decoded token without verifying — anyone can craft a JWT with any claims. Signature verification is mandatory.
- Not checking
cid— if multiple apps can get tokens for your API, usecidto restrict which client can call sensitive endpoints. - Ignoring clock skew — allow 30-60s leeway on
expandnbf. Both jose and python-jose have aclockToleranceoption.
Related Tools
- JWT Decoder Online — paste-and-inspect UI
- JWT RS256 Decoder — RS256 deep dive
- Firebase JWT Decoder — Firebase Auth tokens
- Cognito JWT Decoder — AWS Cognito tokens
- Verify JWT Signature — JWKS & key rotation guide