gotify/server · warning
unknown or expired state
Error message
unknown or expired state
What it means
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.
Source
Thrown at api/oidc.go:378
// $ref: "#/definitions/OIDCExternalTokenRequest"
// responses:
// 200:
// description: Ok
// schema:
// $ref: "#/definitions/OIDCExternalTokenResponse"
// default:
// description: Error
// schema:
// $ref: "#/definitions/Error"
func (a *OIDCAPI) ExternalTokenHandler(ctx *gin.Context) {
var req model.OIDCExternalTokenRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.AbortWithError(http.StatusBadRequest, err)
return
}
session, ok := a.popPendingSession(req.State)
if !ok {
ctx.AbortWithError(http.StatusBadRequest, errors.New("unknown or expired state"))
return
}
exchangeOpts := []rp.CodeExchangeOpt{
rp.CodeExchangeOpt(rp.WithURLParam("redirect_uri", session.RedirectURI)),
rp.WithCodeVerifier(req.CodeVerifier),
}
tokens, err := rp.CodeExchange[*oidc.IDTokenClaims](ctx.Request.Context(), req.Code, a.Provider, exchangeOpts...)
if err != nil {
ctx.AbortWithError(http.StatusUnauthorized, fmt.Errorf("token exchange failed: %w", err))
return
}
info, err := rp.Userinfo[*oidc.UserInfo](ctx.Request.Context(), tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.GetSubject(), a.Provider)
if err != nil {
ctx.AbortWithError(http.StatusInternalServerError, fmt.Errorf("failed to get user info: %w", err))
return
}
user, status, resolveErr := a.resolveUser(tokens.IDTokenClaims, info)
if resolveErr != nil {View on GitHub (pinned to 14bfc25627)
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.
Example fix
// before
curl POST external-token {"state":"abc","code":"..."} // second time with same state
// after
// perform a fresh browser login to obtain a new state, then exchange once Defensive patterns
Strategy: retry
Validate before calling
// Before exchanging, confirm the state came from a currently open login flow
if (!pendingStates.has(state)) { startNewOidcLogin(); return; } Try / catch
try {
await exchangeExternalToken({state, codeVerifier});
} catch (e) {
if (e.response?.status === 400 && /unknown or expired state/.test(e.response.data)) {
restartOidcLogin(); // obtain a fresh state
} else throw e;
} Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- cannot delete internal application
- file with key 'file' must be present
- file must be an image
- invalid file extension
- sort key is not unique
AI-assisted analysis of gotify/server@14bfc25627 (2026-09-05).
Data as JSON: /api/errors/57e31e29c353978d.
Report an issue: GitHub.