Skip to content

Error Handling ​

CyberGo JWT uses the sentinel errors pattern. All errors are checked with errors.Is().

Basic Pattern ​

go
claims, err := processor.Parse(tokenString)
if err != nil {
    switch {
    case errors.Is(err, jwt.ErrTokenExpired):
        // Token expired
    case errors.Is(err, jwt.ErrTokenRevoked):
        // Token revoked
    case errors.Is(err, jwt.ErrTokenInvalidIssuer):
        // Issuer mismatch
    case errors.Is(err, jwt.ErrTokenInvalidAudience):
        // Audience mismatch
    case errors.Is(err, jwt.ErrInvalidToken):
        // Invalid signature or format
    case errors.Is(err, jwt.ErrProcessorClosed):
        // Processor is closed
    default:
        // Other errors
    }
}

Use errors.Is()

Don't use err == jwt.ErrTokenExpired or string matching. errors.Is() correctly handles wrapped errors.

Error Categories ​

Configuration Phase ​

jwt.New() may return these errors:

ErrorCauseSolution
ErrInvalidConfigMultiple invalid config fieldsCheck Config fields
ErrInvalidSecretKeyHMAC key under 32 bytes or weak keyUse a stronger key
ErrInvalidSigningMethodUnsupported signing algorithmUse one of 12 built-in algorithms

Token Operations ​

ErrorMethodSuggestion
ErrEmptyTokenAll token operation methodsCheck request header
ErrInvalidTokenParse, Refresh, ParseInto, RefreshInto, Revoke, IsRevokedSignature mismatch, deny access
ErrAlgorithmMismatchParse, Refresh, ParseInto, RefreshIntoToken algorithm doesn't match config, deny access
ErrExpirationRequiredParse, Refresh, ParseInto, RefreshIntoRequireExpiration enabled but token lacks exp claim
ErrTokenTypeMismatchRefresh, RefreshInto; Parse / ParseInto when RejectRefreshAsAccess is enabledToken type mismatch: access token used to refresh, or refresh token used as an access token, deny access
ErrTokenExpiredParse, Refresh, ParseInto, RefreshIntoPrompt user to refresh token
ErrTokenNotValidYetParse, Refresh, ParseInto, RefreshIntoCheck clock synchronization
ErrTokenInvalidIssuerParse, Refresh, ParseInto, RefreshInto, Revoke, IsRevokedIssuer mismatch
ErrTokenInvalidAudienceParse, Refresh, ParseInto, RefreshInto, Revoke, IsRevokedAudience mismatch
ErrTokenRevokedParse, Refresh, ParseInto, RefreshIntoToken revoked, deny access
ErrInvalidClaimsCreate, CreateRefresh, Parse, Refresh, ParseInto, RefreshIntoBusiness validation failed
ErrTokenMissingIDRevoke, IsRevoked; Refresh / RefreshInto when RotateRefreshTokens is enabledToken missing jti
ErrRefreshRotationFailedRefresh, RefreshInto (when RotateRefreshTokens is enabled)Revoking the old refresh token failed; no new token was issued

Rate Limiting & Blacklist ​

ErrorMethodSuggestion
ErrRateLimitExceededCreate, CreateRefresh, Refresh, RefreshIntoReturn 429
ErrBlacklistNotConfiguredRevoke; Refresh / RefreshInto when RotateRefreshTokens is enabledConfigure blacklist

Lifecycle ​

ErrorMethodSuggestion
ErrProcessorClosedAll methodsRecreate Processor
ErrStoreClosedRevoke, etc.Storage closed

Error Types ​

ValidationError ​

Returned on field-level validation failure, containing specific field and error info:

go
type ValidationError struct {
    Field   string  // Field name that failed
    Message string  // Error description
    Err     error   // Inner error
}

Error Wrapping Chain ​

CyberGo JWT errors fall into sentinel errors (matchable with errors.Is) and wrapped errors (requiring errors.As to extract structured info). Understanding the wrapping chain helps you pinpoint the failure cause.

ValidationError and errors.As ​

Field-level validation failures (length exceeded, injection detected, etc.) return a *ValidationError containing the specific field name and error message. No matter how many layers wrap it, errors.As can pierce through:

go
token, err := processor.Create(claims)
if err != nil {
    var ve *jwt.ValidationError
    if errors.As(err, &ve) {
        fmt.Printf("Field: %s, Reason: %s\n", ve.Field, ve.Message)
        // Field: user_id, Reason: suspicious pattern detected
        return
    }
    // Non-field-level error, go through the errors.Is branches
}

ErrInvalidClaims Wrapping Claims.Validate() ​

Claims.Validate() (or a custom Claims' Validate()) returns a descriptive error (e.g. errors.New("user_id is required")), not a sentinel error. The Processor wraps it as ErrInvalidClaims:

text
invalid claims: user_id is required
└── ErrInvalidClaims (sentinel, outer layer)
    └── user_id is required (descriptive, inner layer)

So the matching is two-layered:

go
if errors.Is(err, jwt.ErrInvalidClaims) {
    // It's the Claims validation failure category
    fmt.Println("Details:", err) // invalid claims: user_id is required
}

ParseUnverified Parse Errors ​

When ParseUnverified encounters a malformed token (e.g., base64 decode failure, JSON parse failure), the returned parse error is a wrapped error, not a sentinel error:

go
err := processor.ParseUnverified(malformedToken, &claims)
if err != nil {
    // ❌ Cannot match the specific cause with errors.Is
    // ✅ Can only determine "parsing failed"
    fmt.Println("Parse failed:", err) // failed to parse token: ...
}

The only two sentinel errors from ParseUnverified are ErrProcessorClosed (Processor is closed) and ErrEmptyToken (empty string passed in); all other format errors cannot be precisely matched with errors.Is.

When to Use errors.Is vs errors.As

  • errors.Is: matches sentinel errors (ErrTokenExpired, ErrInvalidClaims, etc.), used to determine "which category of failure".
  • errors.As: extracts structured errors (*ValidationError), used to find out "exactly which field had what problem".
  • The two can be combined: first use errors.Is to locate the category, then errors.As to extract details.

HTTP Status Code Mapping ​

In a RESTful API, mapping JWT errors to appropriate HTTP status codes is best practice — clients can then distinguish "credential problems" (401), "request format problems" (400), and "server problems" (500).

Mapping Table ​

JWT ErrorHTTP Status CodeClient Action
ErrEmptyToken401 UnauthorizedProvide an auth token
ErrInvalidToken401 UnauthorizedRe-authenticate
ErrAlgorithmMismatch401 UnauthorizedToken source untrusted, re-authenticate
ErrTokenExpired401 UnauthorizedExchange refresh token for a new one
ErrTokenRevoked401 UnauthorizedToken revoked, re-authenticate
ErrTokenInvalidIssuer401 UnauthorizedToken issuer mismatch
ErrTokenInvalidAudience401 UnauthorizedToken audience mismatch
ErrTokenNotValidYet401 UnauthorizedCheck client clock synchronization
ErrTokenTypeMismatch401 UnauthorizedUse the correct refresh token
ErrExpirationRequired401 UnauthorizedToken missing expiration claim
ErrInvalidClaims400 Bad RequestFix Claims content (creation scenario)
ErrRateLimitExceeded429 Too Many RequestsReduce request rate, retry later
ErrProcessorClosed500 Internal Server ErrorServer needs to restart Processor

RESTful Best Practices

  • 401 Unauthorized: all token validity issues (expired, revoked, bad signature, issuer/audience mismatch). The client should guide the user to re-authenticate or refresh the token.
  • 400 Bad Request: Claims validation failure when creating a token — this is a programming error by the caller, not an authentication failure.
  • 429 Too Many Requests: returned when rate limiting triggers, along with a Retry-After header telling the client how long to wait.
  • 500 Internal Server Error: ErrProcessorClosed is a server-side state anomaly and should not be exposed to the client.

Error Handling in Web Services ​

The handler below covers all common errors that Parse may return, and returns appropriate responses per the HTTP Status Code Mapping:

go
package main

import (
    "encoding/json"
    "errors"
    "net/http"

    "github.com/cybergodev/jwt"
)

// authError maps a JWT error to an HTTP status code and message.
func authError(w http.ResponseWriter, err error) {
    w.Header().Set("Content-Type", "application/json")

    switch {
    // Token expired — guide the client to refresh
    case errors.Is(err, jwt.ErrTokenExpired):
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "token_expired",
            "message": "Token has expired, please refresh",
        })

    // Token revoked
    case errors.Is(err, jwt.ErrTokenRevoked):
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "token_revoked",
            "message": "Token has been revoked",
        })

    // Issuer mismatch
    case errors.Is(err, jwt.ErrTokenInvalidIssuer):
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "invalid_issuer",
            "message": "Issuer mismatch",
        })

    // Audience mismatch
    case errors.Is(err, jwt.ErrTokenInvalidAudience):
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "invalid_audience",
            "message": "Audience mismatch",
        })

    // Not yet valid — clock out of sync
    case errors.Is(err, jwt.ErrTokenNotValidYet):
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "token_not_valid_yet",
            "message": "Token is not yet valid",
        })

    // Algorithm mismatch
    case errors.Is(err, jwt.ErrAlgorithmMismatch):
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "algorithm_mismatch",
            "message": "Signing algorithm mismatch",
        })

    // Invalid token (signature error, format error, empty token)
    case errors.Is(err, jwt.ErrInvalidToken),
        errors.Is(err, jwt.ErrEmptyToken),
        errors.Is(err, jwt.ErrExpirationRequired):
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "invalid_token",
            "message": "Invalid token",
        })

    // Claims validation failed — try to extract field-level details
    case errors.Is(err, jwt.ErrInvalidClaims):
        var ve *jwt.ValidationError
        if errors.As(err, &ve) {
            w.WriteHeader(http.StatusBadRequest)
            json.NewEncoder(w).Encode(map[string]string{
                "error":   "validation_failed",
                "field":   ve.Field,
                "message": ve.Message,
            })
        } else {
            w.WriteHeader(http.StatusBadRequest)
            json.NewEncoder(w).Encode(map[string]string{
                "error":   "validation_failed",
                "message": "Claims validation failed",
            })
        }

    // Rate limited
    case errors.Is(err, jwt.ErrRateLimitExceeded):
        w.Header().Set("Retry-After", "60")
        w.WriteHeader(http.StatusTooManyRequests)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "rate_limited",
            "message": "Too many requests, please retry later",
        })

    // System error — Processor is closed
    case errors.Is(err, jwt.ErrProcessorClosed):
        w.WriteHeader(http.StatusInternalServerError)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "internal_error",
            "message": "Service temporarily unavailable",
        })

    // Fallback
    default:
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "auth_failed",
            "message": "Authentication failed",
        })
    }
}

func handleProtected(w http.ResponseWriter, r *http.Request) {
    tokenString := extractToken(r)
    claims, err := processor.Parse(tokenString)
    if err != nil {
        authError(w, err)
        return
    }
    // Authenticated, process the request
    _ = claims
}

Reusing authError

authError is an error-mapping function unrelated to any specific route and can be reused by all handlers that require authentication. It can also be called when handling ErrTokenTypeMismatch in the refresh endpoint.

Next Steps ​