Skip to content

Error Handling

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

Basic Pattern

go
claims, valid, err := processor.Validate(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
ErrInvalidTokenValidate, Refresh, ValidateInto, RefreshInto, Revoke, IsRevokedSignature mismatch, deny access
ErrAlgorithmMismatchValidate, Refresh, ValidateInto, RefreshIntoToken algorithm doesn't match config, deny access
ErrExpirationRequiredValidate, Refresh, ValidateInto, RefreshIntoRequireExpiration enabled but token lacks exp claim
ErrTokenTypeMismatchRefresh, RefreshIntoAccess token (token_type=access) used to refresh, deny access
ErrTokenExpiredValidate, Refresh, ValidateInto, RefreshIntoPrompt user to refresh token
ErrTokenNotValidYetValidate, Refresh, ValidateInto, RefreshIntoCheck clock synchronization
ErrTokenInvalidIssuerValidate, Refresh, ValidateInto, RefreshInto, Revoke, IsRevokedIssuer mismatch
ErrTokenInvalidAudienceValidate, Refresh, ValidateInto, RefreshInto, Revoke, IsRevokedAudience mismatch
ErrTokenRevokedValidate, Refresh, ValidateInto, RefreshIntoToken revoked, deny access
ErrInvalidClaimsCreate, CreateRefresh, Validate, Refresh, ValidateInto, RefreshIntoBusiness validation failed
ErrTokenMissingIDRevoke, IsRevokedToken missing jti

Rate Limiting & Blacklist

ErrorMethodSuggestion
ErrRateLimitExceededCreate, CreateRefresh, Refresh, RefreshIntoReturn 429
ErrBlacklistNotConfiguredRevokeConfigure 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:

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 Validate 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, valid, err := processor.Validate(tokenString)
    if err != nil {
        authError(w, err)
        return
    }
    if !valid {
        authError(w, jwt.ErrInvalidToken)
        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