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
- Ensure the authorization code is fresh and used exactly once
- Send the identical redirect_uri used in the /authorize request
- Verify the PKCE code_verifier matches the code_challenge from step 1
- Check OIDC provider issuer/client-id/secret configuration
- 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
- Use authorization codes immediately — they are single-use and short-lived
- Send the exact redirect_uri used in /authorize
- Store and pass the same PKCE verifier that created the challenge
- Keep provider issuer/secret config in sync
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
- failed to get user info: %w
- basic auth required
- invalid credentials
- issuer url %q is not a valid url: %w
- issuer url %q may not contain a fragment
AI-assisted analysis of gotify/server@14bfc25627 (2026-09-05).
Data as JSON: /api/errors/bfebb896d52a13fb.
Report an issue: GitHub.