L24 · OpenID Connect and SSO
Module 4: Identity and Access · ~20 min · Lesson 24 of 71
🎯 What you'll learn
- What OpenID Connect adds on top of OAuth 2.0, and how an ID token differs from an access token
- How to validate an ID token the way the spec says (signature,
iss,aud,exp,iat,nonce) - How single sign-on gets you into a second app without a password, and why logout is hard
- How discovery documents and JWKS let apps find endpoints and survive key rotation
🔥 Why this matters
On a Monday morning at a company running a stack like the one in these examples, an engineer signs in to the AWS access portal, then opens GitHub, then the Airflow UI, and types a password once. That's single sign-on, and OpenID Connect is one of the protocols underneath it. It's also easy to get subtly wrong. A Kong route that accepts "any token signed by our IdP" can let a token minted for the cafeteria-menu app call a payments API.
1. OAuth answers "may it?", OIDC answers "who?"
In L23, OAuth 2.0 let an app get an access token so it could call an API on a user's behalf. Notice what OAuth never told the app: who the user is. An access token is a key card for the API's doors, and the app isn't supposed to read it.
OpenID Connect (OIDC) is a thin identity layer on top of OAuth 2.0. The client asks for the
openid scope and gets back one extra thing, an ID token: a signed JWT that tells the app who
logged in, when, and how. OIDC renames the roles: the authorization server is the OpenID
Provider (OP), also called the identity provider (IdP), and the client app is the Relying
Party (RP), because it relies on the OP's word about the user.
It's the same authorization code flow with PKCE from L23, with two additions (marked ★):
Browser App (RP) IdP (OP)
│ 1 "Log in" │ │
│─────────────────▶│ │
│ 2 302 to /authorize?scope=openid email │
│ ★ &nonce=n-7f3a9c &code_challenge=... │
│◀─────────────────│ │
│ 3 password + MFA (L21) │
│─────────────────────────────────────────▶│
│ 4 302 back to app with ?code=...&state=..│
│◀─────────────────────────────────────────│
│─────────────────▶│ 5 POST /token │
│ │ code + code_verifier│
│ │──────────────────────▶│
│ │ 6 access_token │
│ │ ★ + id_token │
│ │◀──────────────────────│
│ │ 7 validate id_token, │
│ │ start app session │
│ │ (cookie, L22) │
OIDC also standardizes a UserInfo endpoint (call it with the access token to get profile claims) and discovery (section 4).
2. ID token vs access token
Analogy: the ID token is a letter of introduction addressed to your app; the access token is a key card addressed to an API. Technically, they differ in audience:
| ID token | Access token | |
|---|---|---|
| Who it's for | The client app (RP) | The API (resource server) |
aud claim |
The app's client_id |
The API's identifier |
| Who reads it | The app, which must validate it | The API; the app treats it as opaque |
| Format | Always a JWT | JWT or an opaque string, IdP's choice |
| Job | Prove a login happened | Authorize API calls |
Two classic mistakes come from mixing them up:
MISTAKE 1: ID token sent to an API as a bearer token
claims-spa ──Authorization: Bearer <id_token>──▶ payments-api
aud = "claims-spa" (meant for the SPA)
If payments-api skips the aud check, an ID token from ANY
app at your IdP opens it, including the cafeteria menu.
MISTAKE 2: access token accepted as proof of login
1. Priya uses shady-app, which gets an access token for her
2. shady-app ──POST /login {access_token}──▶ your-app
3. your-app calls /userinfo with it, gets Priya's sub
4. your-app logs shady-app in AS Priya
Mistake 2 works because an access token proves only that some client was authorized. It isn't
addressed to your app and carries no nonce from your login request. Log users in with an ID
token whose aud is your client_id; call APIs with access tokens whose aud is that API.
3. Validating an ID token
Here's a made-up ID token, decoded. Decoding needs no key (L17: signed is not encrypted), which is exactly why decoding alone proves nothing.
HEADER {"alg": "RS256", "kid": "k-2026-10", "typ": "JWT"}
PAYLOAD
{
"iss": "https://idp.example.com", ← who issued it
"sub": "00u8x2k4ZqP1", ← user's ID at this IdP
"aud": "claims-portal-web", ← YOUR client_id
"exp": 1793955900, ← 09:05 UTC: expiry
"iat": 1793955600, ← 09:00 UTC: issued
"auth_time": 1793955591, ← when the user logged in
"nonce": "n-7f3a9c", ← echo of step 2's value
"amr": ["pwd", "otp"], ← how: password + OTP
"email": "priya@example.com", ← profile claim
"email_verified": true
}
The OIDC Core spec (section 3.1.3.7) lists the checks. The core ones:
- Signature: verify with the issuer's public key from its JWKS, matched by
kid. Allow only the algorithms you expect (L25 covers why). issexactly equals the issuer you configured.audcontains yourclient_id. If there are several audiences,azp(authorized party) should equal yourclient_id.expis in the future (a minute of clock-skew leeway is normal).iatisn't unreasonably old (how old is up to you).nonceequals the one you stored in the user's session at step 2. This stops a captured ID token being replayed into a new login.
The required claims are iss, sub, aud, exp and iat. Key your user records on
iss + sub, which the spec says is never reassigned, and not on email, which can change.
4. How SSO works: the IdP's own session
SSO isn't one token shared by every app. Each app gets its own ID token. The magic is a session cookie the IdP sets on its own domain during the first login:
Browser App A IdP App B
│ 1 visit A │ │ │
│─────────────▶│ │ │
│ 2 redirect to IdP │ │
│───────────────────────────▶│ │
│ 3 password + MFA; IdP sets │ │
│ cookie idp_sid=... (IdP's domain) │
│ 4 code → A; A gets id_token(aud=app-a) │
│◀───────────────────────────│ │
│ ... an hour later ... │
│ 5 visit B ──────────────────────────────────▶│
│ 6 redirect to IdP; browser │ │
│ sends idp_sid cookie │ │
│───────────────────────────▶│ │
│ 7 session valid: no prompt │ │
│ code → B; B gets id_token(aud=app-b) │
│◀───────────────────────────│────────────────▶│
So there are three sessions: the IdP's, app A's, and app B's (each a cookie, L22). That's why logout is hard. Clearing app A's cookie leaves the IdP session alive, so the next click on "Log in" signs you straight back in. OIDC has three optional add-on specs, all now final:
- RP-Initiated Logout: the app redirects the browser to the IdP's
end_session_endpoint. - Front-Channel Logout: the IdP loads each app's logout URL in hidden iframes, which third-party cookie blocking increasingly breaks.
- Back-Channel Logout: the IdP POSTs a signed
logout_token(a JWT withsuband/orsid) straight to each app's server. It's the most reliable, but the app must find and kill that session server-side.
Not every IdP supports them, and nothing revokes access tokens already handed to APIs. Short token lifetimes are the backstop.
5. Discovery and JWKS: how apps find keys
Every OP publishes a discovery document at <issuer>/.well-known/openid-configuration: a JSON
file listing its endpoints, supported algorithms, and jwks_uri. The JWKS (JSON Web Key Set)
at that URI holds the OP's public signing keys (L16). Configure an RP with only the issuer URL
and it can find everything else.
JWKS also makes key rotation painless, if the RP behaves:
time ─────────────────────────────────────────────────▶
JWKS: [k1] [k1, k2] [k2]
IdP signs: k1 ────────── k1 ──┐ k2 ───────────────
publish k2 │ switch retire k1
in advance │ to k2 after k1's
│ tokens expire
RP: cache the JWKS (honor Cache-Control). Unknown kid?
Refetch once (rate-limited), then reject.
An RP that hard-codes one key breaks on rotation day. One that refetches the JWKS for every token
hammers the IdP and can be pushed into a self-DoS by tokens with random kid values.
Where OIDC shows up in a typical stack:
| Where | Role |
|---|---|
| AWS IAM Identity Center | The SSO portal; usually federates to a corporate IdP (via SAML 2.0, L26) |
| GitHub | Org and enterprise SSO via SAML; Enterprise Managed Users can use OIDC with Entra ID |
Kong openid-connect plugin |
Makes Kong the RP, or validates bearer tokens at the edge (Enterprise plugin) |
EKS kubectl |
A cluster can trust an external OIDC IdP for user logins |
| EKS IRSA (L19) | The cluster is an OP: it signs pod tokens that AWS STS validates |
🧪 Worked example: an RP's login callback
claims-portal-web (Python, PyJWT 2.x) receives the ID token at step 6. The first version shipped:
# VULNERABLE: decodes, verifies nothing
def login(id_token):
claims = jwt.decode(id_token,
options={"verify_signature": False})
return claims["sub"] # whoever the token says
Anyone can craft a token with "sub": "admin", or reuse an ID token issued to another app. With
verify_signature off, PyJWT also skips exp, iss and aud. The fix:
import time, jwt # PyJWT; LoginError is the app's own
ISSUER = "https://idp.example.com"
CLIENT_ID = "claims-portal-web"
jwks = jwt.PyJWKClient(ISSUER + "/oauth2/v1/keys") # jwks_uri
def login(id_token, expected_nonce):
key = jwks.get_signing_key_from_jwt(id_token) # by kid
claims = jwt.decode(
id_token, key.key,
algorithms=["RS256"], # allow-list, never header
audience=CLIENT_ID, # aud must include us
issuer=ISSUER, # exact match
leeway=60, # clock skew for exp/iat
options={"require":
["iss", "sub", "aud", "exp", "iat"]})
aud = claims["aud"]
if isinstance(aud, list) and len(aud) > 1:
if claims.get("azp") != CLIENT_ID:
raise LoginError("azp mismatch")
if claims.get("nonce") != expected_nonce:
raise LoginError("nonce mismatch") # replay
if claims["iat"] < time.time() - 600:
raise LoginError("token too old")
return (claims["iss"], claims["sub"]) # user key
Tested against six bad tokens (another app's aud, wrong iss, expired, wrong nonce, no exp,
signed with an attacker's key): the vulnerable version logged in all six; the fixed one rejected
all six. decode raises jwt.InvalidTokenError subclasses; catch them and return a 401. In real
code, read jwks_uri from the discovery document rather than hard-coding a path.
🛠 Try it (optional, ~5 min)
Read Google's public discovery document and JWKS. Both are public, read-only, and need no login.
curl -s https://accounts.google.com/.well-known/openid-configuration \
| python3 -m json.tool | head -12
curl -sD - -o /dev/null https://www.googleapis.com/oauth2/v3/certs \
| grep -i cache-control
curl -s https://www.googleapis.com/oauth2/v3/certs \
| python3 -c 'import json,sys
for k in json.load(sys.stdin)["keys"]:
print(k["kid"][:12], k["kty"], k["alg"], k["use"])'
Output from this lesson's run (November 2026; trimmed):
{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/...
"token_endpoint": "https://oauth2.googleapis.com/token",
"userinfo_endpoint": "https://openidconnect.googleapis.com/...
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
...
cache-control: public, max-age=23821, must-revalidate, ...
943a3a5d7d91 RSA RS256 sig
9e7b714e8bfb RSA RS256 sig
What to look for:
- Two keys in the JWKS: rotation in progress, exactly like the diagram. The
kidvalues change over time, so yours will differ. max-ageof a few hours (about 6.6 here; it counts down between runs): how long an RP may cache the keys before fetching them again.id_token_signing_alg_values_supportedis["RS256"](further down the output), and there's noend_session_endpoint: Google doesn't offer RP-initiated logout.- Now try
https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration. Itsissuerishttps://login.microsoftonline.com/{tenantid}/v2.0, a template, because "common" serves every tenant. A multi-tenant RP must check the real tenant iniss, not just the prefix. This one does list anend_session_endpoint.
🧠 Key takeaways
- OIDC = OAuth 2.0 + an ID token: a signed statement to the app about who logged in.
- ID tokens are for the client (
aud=client_id); access tokens are for APIs. Never swap them. - Validate ID tokens fully: signature via JWKS,
iss,aud(andazp),exp,iat,nonce. - SSO works because the IdP keeps its own session; logging out of one app doesn't end it.
- Discovery + JWKS let RPs configure themselves and pick up rotated keys by
kid.
📖 Words to know
| Term | Meaning |
|---|---|
| OpenID Connect (OIDC) | An identity layer on OAuth 2.0 that adds the ID token, UserInfo and discovery |
| OpenID Provider (OP) / IdP | The service that authenticates users and issues ID tokens |
| Relying Party (RP) | An app that relies on the OP to log users in |
| ID token | A signed JWT for the client describing the login: who, when, how |
nonce |
A random value the RP sends at login and checks in the ID token, to stop replay |
azp |
Authorized party: the client the token was issued to, used when there are several audiences |
| Single sign-on (SSO) | Logging in once at an IdP and reaching many apps without logging in again |
| Discovery document | /.well-known/openid-configuration: the OP's endpoints, algorithms and jwks_uri |
| JWKS | JSON Web Key Set: the OP's published public signing keys, each with a kid |
| Back-channel logout | The OP POSTs a signed logout token to each RP's server to end sessions |
⏭ Next time
L25: JWTs and their pitfalls, including alg: none, algorithm confusion, and why "just revoke the
token" is harder than it sounds.
SSO's blast radius: when the IdP is the crown jewel
One key, many doors
SSO concentrates trust. Every app trusts the IdP's word about who you are, so whoever controls the IdP, or holds a live IdP session, controls everything behind it:
┌──────────────────┐
attacker with ──▶ │ IdP │
admin session │ (users, MFA │
or admin creds │ policies, apps)│
└────────┬─────────┘
┌───────────┬─────────┼──────────┬────────────┐
▼ ▼ ▼ ▼ ▼
AWS Identity GitHub Kong- Airflow Jira
Center org fronted UI
(prod accts) (code) APIs
From L03's attacker mindset, this is the shortest path to everything. In the tiering language security teams use, the IdP is tier 0: protected like domain controllers and the root keys.
Case: Okta's support system, 2023
In October 2023 Okta disclosed that an attacker had used a stolen credential to get into its customer support case management system. Okta's root cause analysis says:
- The attacker used a service account stored in the support system. Its credentials had been saved in an employee's personal Google account, which the employee had signed into on an Okta-managed laptop. Okta named the employee's personal account or device as the most likely place the credential leaked.
- Between September 28 and October 17, 2023, the attacker accessed files for 134 customers. Some were HAR files: browser recordings of network traffic that customers upload so support can reproduce a problem.
- HAR files capture headers and cookies, including session tokens. The attacker used tokens from them to hijack the Okta sessions of five customers. 1Password, BeyondTrust and Cloudflare have said publicly they were among them.
- Okta later added that the attacker had also downloaded a report with the names and email addresses of all its customer support system users.
1 employee saves service-account creds in a personal Google
account on a work laptop
2 creds leak (personal account/device most likely)
3 attacker logs in to the support system as the service account
4 downloads customer HAR files ── contain live session cookies
5 replays cookies ──▶ customers' Okta admin consoles
(no password, no MFA: the session already passed them, L22)
6 detected by customers' own monitoring, not first by Okta
Step 6 matters. BeyondTrust says it detected an attempt on one of its own Okta admin accounts on October 2 and reported it to Okta that day; Okta confirmed the breach on October 19.
Why a session token beats MFA
MFA protects the login. The session cookie is the result of the login. An attacker who replays the cookie skips the login entirely, so MFA never runs. This is the same idea as L22's session hijacking, at the scale of every app behind the IdP.
🧪 Worked example: scrub a HAR file before you share it
A vendor's support team (Kong's, say) asks for a HAR of a failing login. Before uploading, strip cookies and auth headers (jq 1.6+):
jq '.log.entries[] |= (
.request.headers |= map(select(.name | ascii_downcase
| IN("cookie", "authorization") | not))
| .response.headers |= map(select(.name | ascii_downcase
| IN("set-cookie") | not))
| .request.cookies = [] | .response.cookies = [])' \
login.har > login-clean.har
grep -ciE 'bearer|sid=|id_token|code=' login-clean.har
On a made-up HAR, the scrubbed file kept the URLs and status codes and lost the sid cookie and
Bearer header. The grep catches what jq doesn't touch: tokens in URLs (?code=,
#id_token=) and POST bodies. A non-zero count means: look, and redact by hand. Some browsers
now offer to export a "sanitized" HAR; still check it.
Defenses that shrink the blast radius
- Bind admin sessions: tie IdP admin sessions to device or network, so a replayed cookie from a new IP fails. Okta added network-based binding for admin sessions after this incident.
- Phishing-resistant MFA for IdP admins (FIDO2/passkeys, L21), and short admin session lifetimes.
- Alert on session anomalies: the same session ID from a new ASN or country, impossible travel, or a new MFA factor added right before a policy change.
- Least privilege for support and service accounts, with no human-saved credentials.
- Treat IdP config as code: changes to apps, MFA policies and admin roles need review and get logged, like changes to prod IAM.
🧠 Extra takeaways
- The IdP is a single point of compromise: protect it, and its admins, as tier 0.
- Session tokens are bearer credentials that bypass MFA. Anything that records them (HAR files, logs, screenshots of dev tools) is sensitive.
- In the Okta case, customers' own detection caught the attack first. Monitor your IdP's logs yourself; don't rely only on the vendor.
| Term | Meaning |
|---|---|
| HAR file | HTTP Archive: a JSON recording of a browser's network traffic, including headers and cookies |
| Session hijacking | Using a stolen session token to act as a logged-in user without logging in |
| Tier 0 | The systems that control identity for everything else, such as the IdP and its admins |
| Session binding | Tying a session to a device or network so a copied token fails elsewhere |
Federation in AWS: Identity Center, SAML/OIDC and short-lived credentials
Two kinds of federation
Federation means AWS trusts an outside identity system instead of storing passwords itself. A stack like the one in these examples uses two kinds daily, often without anyone noticing:
- Workforce federation (people): if your organization uses IAM Identity Center, your corporate IdP vouches for you to it.
- Workload federation (machines): GitHub Actions or an EKS pod vouches for itself with an OIDC token (L19).
Workforce: from your laptop to a prod role
Corporate IdP ──SAML 2.0 assertion (login)──▶ IAM Identity Center
──SCIM (users, groups)───────▶ │
│ user + group
│ → permission set
▼
AWS account 111122223333: IAM role AWSReservedSSO_...
│
STS: temporary keys (1–12 h)
▼
console / aws CLI / kubectl
Identity Center uses SAML 2.0 (L26) to authenticate users with an external IdP and SCIM to
provision users and groups. For your terminal, aws sso login speaks OAuth: since AWS CLI v2.22.0
it uses the authorization code flow with PKCE by default (L23), with --use-device-code for
machines without a browser. The resulting token is cached under ~/.aws/sso/cache/. It's a bearer
token, so treat that folder like a password.
Identity Center can also accept OIDC tokens from your IdP for apps that call AWS on a user's behalf (a trusted token issuer). It finds the IdP's keys through the same discovery document you read in the Try it.
Four sessions, four clocks
"Sign out" means different things at each layer. From the AWS docs:
| Session | Lifetime | Ends when you sign out of the portal? |
|---|---|---|
| Corporate IdP session | Set by your IdP | No (no SAML Single Logout support) |
| Access portal session | 8 h default, 15 min to 90 days | Yes |
| Identity Center app sessions | 1 h, auto-refreshed | Within about 30 minutes |
| IAM role session (permission set) | 1 h default, up to 12 h | No: lasts until it expires |
🧪 Worked example: the offboarding gap
A contractor's access is revoked at 10:00. At 09:30 they had opened a session with the
ProdOps permission set, configured for 12 hours.
09:30 role session issued ──────────────────────────▶ 21:30
10:00 disabled in corporate IdP ─▶ SCIM sync ─▶ Identity Center
portal: blocked once the sync lands
app sessions: gone by ~10:30
aws CLI with cached STS keys: still works until 21:30
You can check what a terminal is really using:
$ aws sts get-caller-identity
{
"UserId": "AROAEXAMPLEID123456:contractor@example.com",
"Account": "111122223333",
"Arn": "arn:aws:sts::111122223333:assumed-role/
AWSReservedSSO_ProdOps_0123456789abcdef/
contractor@example.com"
}
(Arn wrapped for width.) The fixes: shorter session durations on powerful permission sets (1–2
hours for prod), and a tested runbook to cut sessions early. IAM's usual way is a deny policy
keyed on when the session was issued (aws:TokenIssueTime); check AWS's current guidance for
roles that Identity Center manages.
Workloads: OIDC all the way down
IRSA is the main lesson's ID token check, done by AWS STS. The EKS cluster is an OpenID Provider: it has an issuer URL with a discovery document and JWKS, and it signs each pod's service account token. You register that issuer in IAM as an OIDC provider, and the role's trust policy says which tokens count:
"Condition": {
"StringEquals": {
"oidc.eks.us-east-1.amazonaws.com/id/EXAMPLE0123:aud":
"sts.amazonaws.com",
"oidc.eks.us-east-1.amazonaws.com/id/EXAMPLE0123:sub":
"system:serviceaccount:claims:claims-api"
}
}
The pod calls AssumeRoleWithWebIdentity. STS fetches the cluster's JWKS, verifies the signature,
and checks iss, aud, exp and these conditions: the same checklist as section 3. Drop the
sub condition and any service account in the cluster can assume the role, the AWS version of
Mistake 1. GitHub Actions OIDC works the same way, with sub naming the repo and environment.
🧠 Extra takeaways
- AWS workforce SSO is usually SAML 2.0 + SCIM into Identity Center; workloads use OIDC tokens.
- Revoking a user doesn't end IAM role sessions already issued: keep prod sessions short.
- The SSO token cache in
~/.aws/sso/cache/is a bearer credential. - IRSA and GitHub OIDC are ID token validation done by STS; the
audandsubconditions are your audience check.
| Term | Meaning |
|---|---|
| Federation | Trusting an outside identity system to vouch for users or workloads |
| SCIM | A standard API for provisioning users and groups from an IdP into an app |
| Permission set | An Identity Center template that becomes an IAM role in each assigned account |
| Trusted token issuer | An external OAuth/OIDC server whose tokens Identity Center accepts for apps |
| AssumeRoleWithWebIdentity | The STS call that swaps a validated OIDC token for temporary AWS keys |
The quiz for this lesson and the Tests section need JavaScript.