Safeguard
Application Security•

ASVS 5.0 V10.5.1: Validate the Nonce in Every ID Token

An OIDC client must bind each ID Token to the login that requested it. Generate a nonce, store it, and compare it on return. Code and tests for Node and Python.

Safeguard Security Team
6 min read

ASVS 5.0 V10.5.1 (Level 2) requires an OpenID Connect client, the relying party, to defend against ID Token replay. The standard example is the nonce check: send a random value in the authentication request, then confirm the ID Token you get back contains exactly that value.

If your client library has an OIDC mode, it probably does this for you. The task is to confirm it is switched on and that your session handling supports it.

Why it matters

An ID Token is a signed statement that a user logged in. If an attacker captures one, for example from a log or a leaked front channel response, they could feed it to a victim's browser session and log the victim in as the attacker, or log themselves in as the victim.

The nonce ties a token to one specific login attempt in one specific browser session. A token minted for another session carries a different nonce, so it fails.

How to implement it

Per login attempt, generate an unguessable value, keep it server side or in a tamper-proof session cookie, and send it with the request.

import hmac
import secrets
from flask import session, redirect
from urllib.parse import urlencode

@app.get("/login")
def login():
    session["oidc_state"] = secrets.token_urlsafe(32)
    session["oidc_nonce"] = secrets.token_urlsafe(32)
    params = {
        "response_type": "code",
        "client_id": CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "scope": "openid profile",
        "state": session["oidc_state"],
        "nonce": session["oidc_nonce"],
        "code_challenge": pkce_challenge(),
        "code_challenge_method": "S256",
    }
    return redirect(ISSUER_AUTH_URL + "?" + urlencode(params))

After exchanging the code, validate the ID Token and compare the nonce, then discard the stored value so it cannot be used again.

def check_nonce(claims):
    expected = session.pop("oidc_nonce", None)
    got = claims.get("nonce")
    if not expected or not got or not hmac.compare_digest(expected, got):
        raise AuthError("nonce mismatch")

With Node and the openid-client library, pass the nonce to the callback check:

const tokenSet = await client.callback(redirectUri, params, {
  state: req.session.state,
  nonce: req.session.nonce,
  code_verifier: req.session.codeVerifier,
});

The library throws if the nonce is absent or different. Do not catch that error and carry on.

Remember that the nonce check sits beside signature, issuer, audience and expiry checks, not in place of them.

Static analysis can help here, since a missing check is a code level gap. Safeguard's SAST demo shows how code scanning surfaces issues in source:

Click through it at your own pace, or open it full screen.

Related validation steps

The nonce is one check in a list. When your library lets you list them, confirm all of these are enabled: signature against the provider's published keys, issuer equal to the pinned value, audience equal to your client ID, expiry, and the nonce. Reject tokens signed with none or with an algorithm you did not expect. Keep the allowed algorithm list explicit.

Also compare the state parameter from the callback with the stored one before you touch the code. State protects against cross-site request forgery on the callback, while the nonce protects the ID Token itself. They do different jobs, and you need both.

How to test it

  1. Start a login and note the nonce in the authorization URL.
  2. Complete the login and confirm success.
  3. In a test identity provider, or with a mock, return an ID Token with a different nonce. The callback must fail.
  4. Return an ID Token with no nonce claim. The callback must fail.
  5. Start login in browser A, then complete it in browser B using A's response. It should fail on state or nonce.
  6. Try the callback twice with the same response. The second try should fail because the stored nonce was removed.

Automating the check

With a mock provider in your test suite, a small table covers the cases:

@pytest.mark.parametrize("nonce_in_token,expected", [
    ("correct", 200),
    ("wrong",   401),
    (None,      401),
])
def test_callback_nonce(app, mock_idp, nonce_in_token, expected):
    session_nonce = app.start_login()
    token = mock_idp.id_token(nonce=session_nonce if nonce_in_token == "correct" else nonce_in_token)
    assert app.callback(token).status_code == expected

When there is no browser session

Some logins run in places without a cookie session, such as a native app or a single page app with no backend. The principle holds: keep the nonce where only that app instance can read it, such as in memory or in session storage for the tab, and clear it once the response is handled. Do not put it in a long lived store that other tabs or scripts share.

If you use the code flow and receive the ID Token from the token endpoint over TLS, the nonce check still applies. Some teams skip it because the token arrives on a back channel. The requirement does not make that exception, and the check costs a few lines.

What to record as evidence

Keep the test results that show a mismatched nonce and a missing nonce both fail, plus a code reference to where the nonce is generated and compared. If you rely on a library, note its name and version, and add a unit test that fails if someone removes the nonce option from your configuration. That way the control survives a refactor.

Common mistakes

  • A fixed or timestamp based nonce.
  • Skipping the check when the claim is missing, treating absence as a pass.
  • Keeping the nonce in a cookie the client can edit and that is not signed.
  • Reusing one nonce across several login attempts.
  • Turning off validation in the library to get past a configuration error in development, then shipping that.

Quick check

Where should the nonce be stored between the request and the callback?

In server side session state or a signed, tamper-proof cookie, tied to the browser that began the login.

What if the ID Token has no nonce claim?

Reject it. The client sent a nonce, so a token without one cannot be tied to this login.

Sources

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.