siyuan-note/siyuan · error

OIDC nonce does not match

Error message

OIDC nonce does not match

What it means

Exchange compares the nonce embedded in the verified ID token with the nonce that was originally bound to the authorization request (via AuthCodeURL's oidc.Nonce option). A mismatch means the returned token was not minted in response to this client's request — a replay/CSRF-style protection — so the sign-in is aborted. Typically indicates mixed-up or replayed state across concurrent sign-in sessions.

Solutions

  1. Restart the sign-in flow from the beginning so a fresh nonce is generated, bound to the authorization URL, and stored with the pending state.
  2. Ensure the nonce stored at authorization-start is the exact value passed to Exchange (same session key, no overwrites).
  3. Make pending sign-in state (state + nonce + verifier) shared and consistent if the kernel runs multiple instances (e.g. store in the DB/cache, not process memory).
  4. Advise users not to run concurrent sign-ins in multiple tabs, or key pending state per browser tab/session id.

Example fix

// before
nonce := randomToken()
url := provider.AuthCodeURL(state, oidc.Nonce(newRandomNonce())) // nonce mismatch: different value stored
sessions[state] = nonce
// after
nonce := randomToken()
url := provider.AuthCodeURL(state, oidc.Nonce(nonce)) // bind the SAME nonce
sessions[state] = nonce
Defensive patterns

Strategy: validation

Validate before calling

stored, ok := pendingSessions[state]
if !ok || stored.Nonce == "" {
    http.Error(w, "unknown or expired sign-in state, restart", http.StatusBadRequest)
    return
}

Try / catch

claims, err := provider.Exchange(ctx, code, verifier, nonce)
if err != nil {
    if err.Error() == "OIDC nonce does not match" {
        // invalidate the pending session and redirect the user to restart sign-in
    }
    return err
}

Prevention

When it happens

Trigger: Calling Exchange with a nonce value different from the one used when generating the authorization URL: the caller regenerated AuthCodeURL with a new nonce but validated against an old stored nonce; two browser tabs overwrote each other's pending sign-in state; the state/nonce store returned the wrong entry.

Common situations: Users opening the sign-in flow in two tabs and completing the older one; server-side session/state storage keyed incorrectly (per-instance vs shared cache); a restart clearing in-memory pending state; proxies caching the authorization redirect.

Related errors


AI-assisted analysis of siyuan-note/siyuan@9f775e8a12 (2026-09-19). Data as JSON: /api/errors/72b01c018392fc31. Report an issue: GitHub.

Appendix: 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 9f775e8a12)