{"record":{"id":"57e31e29c353978d","repo":"gotify/server","slug":"unknown-or-expired-state","errorCode":null,"errorMessage":"unknown or expired state","messagePattern":"unknown or expired state","errorType":"http","errorClass":null,"httpStatus":400,"severity":"warning","filePath":"api/oidc.go","lineNumber":378,"sourceCode":"//\t    $ref: \"#/definitions/OIDCExternalTokenRequest\"\n//\tresponses:\n//\t  200:\n//\t    description: Ok\n//\t    schema:\n//\t        $ref: \"#/definitions/OIDCExternalTokenResponse\"\n//\t  default:\n//\t    description: Error\n//\t    schema:\n//\t        $ref: \"#/definitions/Error\"\nfunc (a *OIDCAPI) ExternalTokenHandler(ctx *gin.Context) {\n\tvar req model.OIDCExternalTokenRequest\n\tif err := ctx.ShouldBindJSON(&req); err != nil {\n\t\tctx.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\tsession, ok := a.popPendingSession(req.State)\n\tif !ok {\n\t\tctx.AbortWithError(http.StatusBadRequest, errors.New(\"unknown or expired state\"))\n\t\treturn\n\t}\n\texchangeOpts := []rp.CodeExchangeOpt{\n\t\trp.CodeExchangeOpt(rp.WithURLParam(\"redirect_uri\", session.RedirectURI)),\n\t\trp.WithCodeVerifier(req.CodeVerifier),\n\t}\n\ttokens, err := rp.CodeExchange[*oidc.IDTokenClaims](ctx.Request.Context(), req.Code, a.Provider, exchangeOpts...)\n\tif err != nil {\n\t\tctx.AbortWithError(http.StatusUnauthorized, fmt.Errorf(\"token exchange failed: %w\", err))\n\t\treturn\n\t}\n\tinfo, err := rp.Userinfo[*oidc.UserInfo](ctx.Request.Context(), tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.GetSubject(), a.Provider)\n\tif err != nil {\n\t\tctx.AbortWithError(http.StatusInternalServerError, fmt.Errorf(\"failed to get user info: %w\", err))\n\t\treturn\n\t}\n\tuser, status, resolveErr := a.resolveUser(tokens.IDTokenClaims, info)\n\tif resolveErr != nil {","sourceCodeStart":360,"sourceCodeEnd":396,"githubUrl":"https://github.com/gotify/server/blob/14bfc256276775c425f988d621dccfe705de18ac/api/oidc.go#L360-L396","documentation":"ExternalTokenHandler throws 400 'unknown or expired state' when the OIDC code-exchange request carries a state value that is not in the server's pending-session store. The state is consumed on first use and entries expire, so this protects against CSRF and replay.","triggerScenarios":"POSTing to the external-token endpoint with req.State that was never issued, already consumed by a prior exchange, evicted by expiry/TTL, or lost due to a server restart between redirect and callback.","commonSituations":"Double-submitting the callback (browser retry/back button); user sitting on the login redirect too long; Gotify restarted mid-login; manually replaying a captured state in tests or curl; load-balanced setup without shared session storage.","solutions":["Restart the OIDC login flow from the beginning to get a fresh state.","Do not reuse a state value; each can be exchanged exactly once.","Retry promptly after the redirect rather than after a long delay.","For HA deployments, ensure pending sessions are stored in shared storage and avoid restarting Gotify during login."],"exampleFix":"// before\ncurl POST external-token {\"state\":\"abc\",\"code\":\"...\"} // second time with same state\n// after\n// perform a fresh browser login to obtain a new state, then exchange once","handlingStrategy":"retry","validationCode":"// Before exchanging, confirm the state came from a currently open login flow\nif (!pendingStates.has(state)) { startNewOidcLogin(); return; }","typeGuard":null,"tryCatchPattern":"try {\n  await exchangeExternalToken({state, codeVerifier});\n} catch (e) {\n  if (e.response?.status === 400 && /unknown or expired state/.test(e.response.data)) {\n    restartOidcLogin(); // obtain a fresh state\n  } else throw e;\n}","preventionTips":["Never reuse a state value; exchange exactly once.","Complete the login promptly; avoid idle redirects.","Avoid restarting Gotify mid-login.","In HA setups, use shared session/state storage."],"tags":["oidc","csrf","http-400","session"],"backgroundTag":"oidc-state-mismatch","analyzedSha":"14bfc256276775c425f988d621dccfe705de18ac","analyzedAt":"2026-09-05T12:52:36.781Z","contentChangedAt":"2026-09-05T12:52:36.781Z","schemaVersion":2},"datasetVersion":"2026-09-12T17:17:11.597Z"}