Building a Secure JWT Issuer in Go: A Complete Guide

JSON Web Tokens (JWT) have become the de facto standard for implementing stateless authentication in modern web applications. In this guide, we’ll implement a secure JWT issuer in Go, covering both basic implementation and advanced security considerations.

Understanding JWT Basics

A JWT consists of three parts: header, payload, and signature. These parts are Base64URL encoded and concatenated with dots. The signature ensures the token hasn’t been tampered with, while the payload carries the claims (data) we want to transmit securely.

First, let’s create a basic JWT issuer that can generate tokens with custom claims.

package main

import (
    "fmt"
    "time"
    "github.com/golang-jwt/jwt/v5"
)

type Claims struct {
    UserID    string   `json:"uid"`
    Username  string   `json:"username"`
    Roles     []string `json:"roles"`
    jwt.RegisteredClaims
}

type JWTIssuer struct {
    secretKey []byte
    duration  time.Duration
}

func NewJWTIssuer(secretKey string, duration time.Duration) *JWTIssuer {
    return &JWTIssuer{
        secretKey: []byte(secretKey),
        duration:  duration,
    }
}

func (i *JWTIssuer) IssueToken(userID, username string, roles []string) (string, error) {
    now := time.Now()
    claims := Claims{
        UserID:    userID,
        Username:  username,
        Roles:     roles,
        RegisteredClaims: jwt.RegisteredClaims{
            IssuedAt:  jwt.NewNumericDate(now),
            ExpiresAt: jwt.NewNumericDate(now.Add(i.duration)),
            NotBefore: jwt.NewNumericDate(now),
            Issuer:    "your-service-name",
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(i.secretKey)
}

Basic Usage Example

Here’s how to use the basic JWT issuer:

func main() {
    // Create a new issuer with a 24-hour token duration
    issuer := NewJWTIssuer("your-secret-key", 24*time.Hour)

    // Issue a token
    token, err := issuer.IssueToken(
        "user123",
        "john.doe",
        []string{"user", "admin"},
    )
    if err != nil {
        panic(err)
    }

    fmt.Println("Generated Token:", token)
}

Adding Token Validation

It’s crucial to implement token validation to ensure the tokens we receive are valid and haven’t been tampered with.

func (i *JWTIssuer) ValidateToken(tokenString string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
        // Validate the signing method
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
        }
        return i.secretKey, nil
    })

    if err != nil {
        return nil, fmt.Errorf("invalid token: %w", err)
    }

    if claims, ok := token.Claims.(*Claims); ok && token.Valid {
        return claims, nil
    }

    return nil, fmt.Errorf("invalid token claims")
}

Enhanced Security Features

Let’s add some security enhancements to our JWT issuer.

Token Revocation Support

We’ll implement a simple token blacklist using Redis to support token revocation:

import (
    "context"
    "github.com/redis/go-redis/v9"
)

type EnhancedJWTIssuer struct {
    *JWTIssuer
    redis *redis.Client
}

func NewEnhancedJWTIssuer(secretKey string, duration time.Duration, redisURL string) (*EnhancedJWTIssuer, error) {
    opt, err := redis.ParseURL(redisURL)
    if err != nil {
        return nil, err
    }

    return &EnhancedJWTIssuer{
        JWTIssuer: NewJWTIssuer(secretKey, duration),
        redis:     redis.NewClient(opt),
    }, nil
}

func (i *EnhancedJWTIssuer) RevokeToken(ctx context.Context, token string) error {
    claims, err := i.ValidateToken(token)
    if err != nil {
        return err
    }

    // Store the token in the blacklist until its original expiration
    expiration := time.Until(claims.ExpiresAt.Time)
    return i.redis.Set(ctx, "blacklist:"+token, true, expiration).Err()
}

func (i *EnhancedJWTIssuer) IsTokenRevoked(ctx context.Context, token string) bool {
    exists, err := i.redis.Exists(ctx, "blacklist:"+token).Result()
    return err == nil && exists > 0
}

Rate Limiting Token Generation

We’ll add rate limiting to prevent token generation abuse:

import (
    "golang.org/x/time/rate"
    "sync"
)

type RateLimitedJWTIssuer struct {
    *EnhancedJWTIssuer
    limiters   map[string]*rate.Limiter
    limitersMu sync.RWMutex
}

func NewRateLimitedJWTIssuer(secretKey string, duration time.Duration, redisURL string) (*RateLimitedJWTIssuer, error) {
    enhanced, err := NewEnhancedJWTIssuer(secretKey, duration, redisURL)
    if err != nil {
        return nil, err
    }

    return &RateLimitedJWTIssuer{
        EnhancedJWTIssuer: enhanced,
        limiters:          make(map[string]*rate.Limiter),
    }, nil
}

func (i *RateLimitedJWTIssuer) getLimiter(userID string) *rate.Limiter {
    i.limitersMu.RLock()
    limiter, exists := i.limiters[userID]
    i.limitersMu.RUnlock()

    if exists {
        return limiter
    }

    i.limitersMu.Lock()
    defer i.limitersMu.Unlock()

    limiter = rate.NewLimiter(rate.Every(time.Minute), 10) // 10 tokens per minute
    i.limiters[userID] = limiter
    return limiter
}

func (i *RateLimitedJWTIssuer) IssueTokenWithRateLimit(ctx context.Context, userID, username string, roles []string) (string, error) {
    limiter := i.getLimiter(userID)
    if !limiter.Allow() {
        return "", fmt.Errorf("rate limit exceeded for user %s", userID)
    }

    return i.IssueToken(userID, username, roles)
}

Using the Enhanced JWT Issuer in a Web Application

Here’s an example of how to use our enhanced JWT issuer in a web application:

func main() {
    issuer, err := NewRateLimitedJWTIssuer(
        "your-secret-key",
        24*time.Hour,
        "redis://localhost:6379/0",
    )
    if err != nil {
        panic(err)
    }

    http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
        // Authentication logic here...
        userID := "user123"
        username := "john.doe"
        roles := []string{"user", "admin"}

        token, err := issuer.IssueTokenWithRateLimit(r.Context(), userID, username, roles)
        if err != nil {
            http.Error(w, err.Error(), http.StatusTooManyRequests)
            return
        }

        json.NewEncoder(w).Encode(map[string]string{
            "token": token,
        })
    })

    http.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token == "" {
            http.Error(w, "no token provided", http.StatusBadRequest)
            return
        }

        // Remove "Bearer " prefix if present
        token = strings.TrimPrefix(token, "Bearer ")

        if err := issuer.RevokeToken(r.Context(), token); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        w.WriteHeader(http.StatusOK)
    })

    http.ListenAndServe(":8080", nil)
}

Best Practices and Security Considerations

  1. Secret Key Management

    • Never hardcode secret keys
    • Use environment variables or secure key management services
    • Rotate keys periodically
  2. Token Expiration

    • Set reasonable expiration times
    • Consider using refresh tokens for long-term sessions
    • Implement token rotation for sensitive operations
  3. Claims Security

    • Include only necessary information in claims
    • Avoid sensitive data in tokens
    • Use standard claims when possible
  4. Validation

    • Always validate tokens before trusting them
    • Check signature, expiration, and issuer
    • Implement proper error handling

Conclusion

Implementing a JWT issuer in Go requires careful consideration of security aspects beyond just token generation. By including features like token revocation, rate limiting, and proper validation, we can create a robust authentication system that’s both secure and scalable.

The implementation provided here serves as a foundation that you can build upon based on your specific requirements. Remember to always follow security best practices and keep your dependencies updated to maintain a secure system.

References