gotify/server · error

token exchange failed: %w

Error message

token exchange failed: %w

What it means

During the OIDC authorization-code flow in ExternalTokenHandler, `rp.CodeExchange` (from zitadel/oidc rp package) failed to exchange the authorization code (plus PKCE verifier) for tokens at the provider's token endpoint. The handler wraps the underlying error with 'token exchange failed: %w' and returns 401.

Source

Thrown at api/oidc.go:387

//	        $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 {
		ctx.AbortWithError(status, resolveErr)
		return
	}
	client, err := a.createClient(session.ClientName, user.ID)
	if err != nil {
		ctx.AbortWithError(http.StatusInternalServerError, err)
		return
	}
	ctx.JSON(http.StatusOK, &model.OIDCExternalTokenResponse{

View on GitHub (pinned to 14bfc25627)

Solutions

  1. Ensure the authorization code is fresh and used exactly once
  2. Send the identical redirect_uri used in the /authorize request
  3. Verify the PKCE code_verifier matches the code_challenge from step 1
  4. Check OIDC provider issuer/client-id/secret configuration
  5. Inspect the wrapped cause (`%w`) in logs for the provider's exact error

Example fix

// before
rp.WithCodeVerifier(storedOtherVerifier)
// after
rp.WithCodeVerifier(session.CodeVerifier) // same verifier that generated the code_challenge
Defensive patterns

Strategy: try-catch

Validate before calling

// before exchanging, verify session state
if (!session || !session.CodeVerifier || !req.Code) throw new Error('missing code or PKCE verifier');
if (req.RedirectURI !== session.RedirectURI) throw new Error('redirect_uri mismatch');

Try / catch

try {
  tokens = await exchange(code, verifier, redirectURI);
} catch (err) {
  if (isInvalidGrant(err)) return startNewLoginFlow(); // code expired/used
  throw err;
}

Prevention

When it happens

Trigger: POST to the external token endpoint with an invalid/expired/already-used `code`, a wrong `code_verifier` (PKCE mismatch), a mismatched `redirect_uri`, or the provider's token endpoint being unreachable/misconfigured.

Common situations: Replaying an authorization code (codes are single-use); user taking too long so the code expired; redirect_uri differing from the one used in the authorize request; client secret/issuer config changed; clock skew.

Related errors


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