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.

Solutions

  1. Restart the OIDC login flow from the beginning to get a fresh state.
  2. Do not reuse a state value; each can be exchanged exactly once.
  3. Retry promptly after the redirect rather than after a long delay.
  4. 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

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


AI-assisted analysis of gotify/server@14bfc25627 (2026-09-05). Data as JSON: /api/errors/57e31e29c353978d. Report an issue: GitHub.

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