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
- 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.
- Ensure the nonce stored at authorization-start is the exact value passed to Exchange (same session key, no overwrites).
- 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).
- 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
- Bind one nonce per sign-in attempt and store it with the same state key used at Exchange time
- Expire and clear pending sessions on kernel restart or use persistent storage
- Guard against concurrent sign-ins overwriting pending state (per-session keys)
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
- OIDC issuer URL must use HTTPS unless it is a loopback…
- verify OIDC ID token failed
- A loopback OIDC redirect URL is required for local access
- A public HTTPS OIDC redirect URL is required for remote…
- Access to encrypted notebook data is not supported via this…
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)