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

  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.