siyuan-note/siyuan · error

OIDC nonce does not match

Error message

OIDC nonce does not match

What it means

Thrown by Provider.Exchange() when idToken.Nonce does not equal the nonce argument. The nonce is a single-use random string sent in the authorization request and embedded in the id_token to prevent replay/token-injection attacks. A mismatch indicates the token was not issued for this specific login attempt.

Source

Thrown at kernel/model/oidc_provider/provider.go:108

func (p *Provider) Exchange(ctx context.Context, code, codeVerifier, nonce string) (map[string]any, error) {
	token, err := p.oauth2Config.Exchange(ctx, code, oauth2.VerifierOption(codeVerifier))
	if err != nil {
		return nil, fmt.Errorf("exchange OIDC authorization code failed: %w", err)
	}
	if p.kind == conf.OIDCProviderGitHub {
		return exchangeGitHubClaims(ctx, token)
	}
	rawIDToken, ok := token.Extra("id_token").(string)
	if !ok || rawIDToken == "" {
		return nil, errors.New("OIDC response does not contain an ID token")
	}
	idToken, err := p.verifier.Verify(ctx, rawIDToken)
	if err != nil {
		return nil, fmt.Errorf("verify OIDC ID token failed: %w", err)
	}
	if idToken.Nonce != nonce {
		return nil, errors.New("OIDC nonce does not match")
	}
	claims := map[string]any{}
	if err = idToken.Claims(&claims); err != nil {
		return nil, fmt.Errorf("decode OIDC claims failed: %w", err)
	}
	return claims, nil
}

func newGitHub(config *conf.OIDC, redirectURL string) *Provider {
	scopes := append([]string{}, config.Scopes...)
	if len(scopes) == 0 || isDefaultOIDCScopes(scopes) {
		scopes = []string{"read:user", "user:email"}
	} else {
		filtered := scopes[:0]
		for _, scope := range scopes {
			if scope != oidc.ScopeOpenID && scope != "profile" && scope != "email" {
				filtered = append(filtered, scope)
			}

View on GitHub (pinned to 251596fc0d)

Solutions

  1. Ensure the nonce generated in AuthURL is persisted (session, signed cookie, or database) and correctly retrieved in the callback handler.
  2. Verify the nonce passed to Exchange is the exact same value passed to AuthURL for this login session.
  3. Use a per-session nonce store that survives the redirect (e.g., signed HTTP-only cookie rather than in-memory map).
  4. If the server restarts mid-login, the user must re-initiate the OIDC flow to get a new nonce.

Example fix

// before
nonce := generateNonce()
authURL := provider.AuthURL(state, nonce, codeVerifier)
// ... redirect user ...
// On callback:
claims, err := provider.Exchange(ctx, code, codeVerifier, generateNonce()) // fresh nonce!

// after
nonce := generateNonce()
session.Set("oidc_nonce", nonce)
session.Save()
authURL := provider.AuthURL(state, nonce, codeVerifier)
// ... redirect user ...
// On callback:
storedNonce := session.Get("oidc_nonce").(string)
claims, err := provider.Exchange(ctx, code, codeVerifier, storedNonce)
Defensive patterns

Strategy: validation

Validate before calling

// Ensure nonce is persisted and retrieved correctly
if nonce == "" {
    return errors.New("nonce is empty; cannot verify OIDC response")
}
// Compare with stored nonce from the auth request
storedNonce := session.Get("oidc_nonce")
if storedNonce != nonce {
    return errors.New("nonce mismatch detected before exchange")
}

Type guard

func isValidNonce(stored, received string) bool {
    return stored != "" && received != "" && stored == received
}

Try / catch

claims, err := provider.Exchange(ctx, code, codeVerifier, nonce)
if err != nil && strings.Contains(err.Error(), "nonce does not match") {
    // Session lost the nonce or potential replay — force re-authentication
    session.Delete("oidc_nonce")
    http.Redirect(w, r, "/api/oidc/login", http.StatusTemporaryRedirect)
    return
}

Prevention

When it happens

Trigger: Calling Exchange(ctx, code, codeVerifier, nonce) where the nonce extracted from the verified id_token differs from the nonce passed in. This occurs when: the nonce was not persisted correctly between AuthURL generation and the callback, a different nonce was used in AuthURL vs Exchange, or an attacker attempted a token injection/replay.

Common situations: The nonce was generated for AuthURL but stored in a session/cookie that expired or was cleared before the callback, so a fresh/empty nonce is compared. The server restarted between generating the auth URL and handling the callback, losing the in-memory nonce. Multiple concurrent login flows mixed up nonces. A genuine replay attack attempt (rare but this is the security guard catching it).

Related errors


AI-assisted analysis of siyuan-note/siyuan@251596fc0d (2026-08-12). Data as JSON: /api/errors/72b01c018392fc31. Report an issue: GitHub.