Single sign-on is a relay, not a password handover. The user proves their identity to an identity provider once, and that provider hands each app a signed assertion or token. The app never stores a password; it stores the provider’s public key, the issuer it expects, and the audience it answers to.

That division of labour is the whole design, and it moves the risk rather than removing it. The app’s security now rests entirely on verification steps it can skip by accident, and the token becomes the thing worth stealing.

Three parties, two encodings

SAML and OIDC are the same relay in two encodings: an XML assertion with an XML-DSig signature, or a JSON Web Token with a JWS signature. Both carry an issuer, a subject, an audience and an expiry, and both are verified against metadata the provider publishes — a signing certificate or a JWKS endpoint. OIDC Core is explicit about two of the checks: the issuer identifier from discovery must exactly match the iss claim, and the token must be rejected if the audience does not list the client.

The audience check is what stops a relay from becoming a skeleton key. A token minted for the analytics dashboard is a valid, correctly signed token — and the billing app must still refuse it. That is why aud is not a formality: without it, every app that trusts the same provider can be entered with a token issued for any other app.

Verification is four checks, not one

The signature is only the first check, and it must be made over the exact received bytes with an algorithm the app chose, never one the token named. RFC 8725 documents both classic failures: an attacker changing alg to none so a library skips verification, and changing RS256 to HS256 so a library HMACs with the RSA public key as the shared secret. In the run behind this note an unsigned token and an HS256 token keyed with the public key were both accepted by a verifier that read alg from the header, and both rejected by a verifier that pinned RS256 in configuration.

Then come the claims. A payload byte flipped in transit failed the signature check. A token with exp an hour in the past still verified cryptographically and was rejected on its claims — the signature says the token is authentic, not that it is current. RFC 7519 allows “some small leeway, usually no more than a few minutes” for clock skew, and 30 seconds of skew was accepted under a 60-second leeway. Finally the jti is checked against a revocation list, because a signature that verifies says nothing about whether the session should still exist.

What the relay cannot do for you

Revocation is the part SSO does not solve by itself. Logging out of the provider ends the provider’s session; the app’s own session lives until its token expires unless the app actively ends it. A short exp with a refresh token, plus back-channel logout or token introspection, is what closes the loop. Relying on the provider alone leaves a window measured in the token’s lifetime, and that window is the whole exposure.

When it breaks

The first failure is a relay that trusts without checking. A misconfigured library that honours the alg header, or an assertion whose signature is never validated because it arrived over TLS, accepts anything — including a forged sub naming an administrator. It presents as a working login, which is why it survives review: nothing errors, and there is no password database whose emptiness would give the gap away.

The second is time and revocation. Clocks that disagree beyond the leeway make every freshly issued token look not yet valid or already expired, an outage that looks like a provider problem and is really NTP. A generous leeway hides that by widening the window in which a revoked token still works. And an offboarded employee whose provider account is disabled keeps their app session until it expires, which is the difference between minutes and days.

Check it quickly

Decode the token before trusting it:

python3 -c 'import base64,json,sys; b=sys.argv[1].split("."); d=lambda s: base64.urlsafe_b64decode(s+"="*(-len(s)%4)); print(json.loads(d(b[0])))' "$TOKEN"
python3 -c 'import base64,json,sys,time; b=sys.argv[1].split("."); d=lambda s: base64.urlsafe_b64decode(s+"="*(-len(s)%4)); p=json.loads(d(b[1])); print(p["iss"], p["aud"], p["exp"]-int(time.time()))' "$TOKEN"
curl -s https://idp.example.com/.well-known/openid-configuration | jq -r '.issuer, .jwks_uri'

The header should name RS256 or ES256 — none, HS256 and an unexpected kid are the findings, not the noise. The second line prints the issuer, the audience and the seconds until expiry: a negative number means the token is already expired, and an audience that is not your client id means it was minted for something else. The issuer from the metadata must match the iss claim byte for byte.

Sources