CyberNugget

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:

  1. Signature: verify with the issuer's public key from its JWKS, matched by kid. Allow only the algorithms you expect (L25 covers why).
  2. iss exactly equals the issuer you configured.
  3. aud contains your client_id. If there are several audiences, azp (authorized party) should equal your client_id.
  4. exp is in the future (a minute of clock-skew leeway is normal).
  5. iat isn't unreasonably old (how old is up to you).
  6. nonce equals 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 with sub and/or sid) 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 kid values change over time, so yours will differ.
  • max-age of 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_supported is ["RS256"] (further down the output), and there's no end_session_endpoint: Google doesn't offer RP-initiated logout.
  • Now try https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration. Its issuer is https://login.microsoftonline.com/{tenantid}/v2.0, a template, because "common" serves every tenant. A multi-tenant RP must check the real tenant in iss, not just the prefix. This one does list an end_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 (and azp), 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.

Deep dive 1

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
Deep dive 2

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 aud and sub conditions 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.