Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 033 / 100

JWT authentication in Go: a hands-on guide for engineers

Secure APIs with tokens.

Expona Admin9 min read

Introduction

Token-based authentication is the de facto standard for modern APIs, and JWT (JSON Web Token) remains the most practical choice for stateless auth across services. Many teams reach for a JWT library and call it secure—but the real work happens in validation. JWT authentication in Go: a hands-on guide for engineers covers the concrete patterns we use in production systems, from explicit algorithm whitelisting to audience and issuer checks that most tutorials skip.

This isn't theory. We've shipped this in e-commerce platforms handling thousands of concurrent users, trading systems where auth failures cost money, and SaaS backends where token replay is a genuine threat. Let's walk through what actually matters.

Validate tokens with explicit algorithms

The first rule: never trust the algorithm field in a JWT header. An attacker can downgrade RS256 to HS256 (symmetric), then sign tokens with your public key as the shared secret. We've seen this in the wild. Always whitelist acceptable algorithms at parse time.

Here's the pattern we use. The jwt.Parse function from golang-jwt/jwt accepts a key function and validation options. We pass jwt.WithValidMethods to lock it down:

go
package auth

import (
	"crypto/rsa"
	"fmt"
	"log"

	"github.com/golang-jwt/jwt/v5"
)

type Claims struct {
	UserID   string `json:"sub"`
	Email    string `json:"email"`
	Audience jwt.ClaimStrings `json:"aud"`
	Issuer   string `json:"iss"`
	jwt.RegisteredClaims
}

func ValidateToken(raw string, publicKey *rsa.PublicKey) (*Claims, error) {
	token, err := jwt.ParseWithClaims(
		raw,
		&Claims{},
		func(token *jwt.Token) (interface{}, error) {
			// Verify the signing method first
			if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
				return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
			}
			return publicKey, nil
		},
		jwt.WithValidMethods([]string{"RS256"}), // Explicit whitelist
	)

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

	claims, ok := token.Claims.(*Claims)
	if !ok || !token.Valid {
		return nil, fmt.Errorf("invalid token claims")
	}

	return claims, nil
}

Notice the two-layer validation: we check the method type in the key function itself, then use jwt.WithValidMethods as a belt-and-suspenders approach. The ParseWithClaims signature lets us work with a custom claims struct, which we'll need for audience and issuer checks next.

In production, load your RSA public key once at startup—don't re-parse it on every request. Store it in a package variable or inject it into your handler. Same applies if you use JWKS (JSON Web Key Set) endpoints; cache aggressively and refresh on a schedule or via rotation signals.

Implement auth middleware

A single validation function isn't enough. We need middleware that extracts the bearer token from the request, validates it, and makes the claims available to downstream handlers. In Go, the standard pattern uses context.

Here's middleware we've deployed in NestJS-adjacent architectures (via Go's net/http and third-party routers like Gin or Echo):

go
package auth

import (
	"context"
	"fmt"
	"net/http"
	"strings"
)

type contextKey string

const ClaimsContextKey contextKey = "claims"

// AuthMiddleware extracts and validates the JWT from the Authorization header.
func AuthMiddleware(publicKey *rsa.PublicKey) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			authHeader := r.Header.Get("Authorization")
			if authHeader == "" {
				http.Error(w, "missing authorization header", http.StatusUnauthorized)
				return
			}

			parts := strings.SplitN(authHeader, " ", 2)
			if len(parts) != 2 || parts[0] != "Bearer" {
				http.Error(w, "invalid authorization format", http.StatusUnauthorized)
				return
			}

			token := parts[1]
			claims, err := ValidateToken(token, publicKey)
			if err != nil {
				http.Error(w, fmt.Sprintf("token validation failed: %v", err), http.StatusUnauthorized)
				return
			}

			// Store claims in context for use in handlers.
			ctx := context.WithValue(r.Context(), ClaimsContextKey, claims)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// ClaimsFromContext retrieves the validated claims from the request context.
func ClaimsFromContext(ctx context.Context) (*Claims, error) {
	claims, ok := ctx.Value(ClaimsContextKey).(*Claims)
	if !ok {
		return nil, fmt.Errorf("claims not found in context")
	}
	return claims, nil
}

This is straightforward but production-solid. We extract the bearer token, call our validation function, and attach the claims to the context using Go's standard context.WithValue. Downstream handlers can retrieve claims with ClaimsFromContext(r.Context()).

For router-agnostic code, wrap the middleware in an adapter. If you use Gin, for example, convert the middleware to a gin.HandlerFunc:

go
// Gin adapter
func AuthMiddlewareGin(publicKey *rsa.PublicKey) gin.HandlerFunc {
	return func(c *gin.Context) {
		authHeader := c.GetHeader("Authorization")
		if authHeader == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
			return
		}

		parts := strings.SplitN(authHeader, " ", 2)
		if len(parts) != 2 || parts[0] != "Bearer" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid format"})
			return
		}

		claims, err := ValidateToken(parts[1], publicKey)
		if err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
			return
		}

		c.Set("claims", claims)
		c.Next()
	}
}

Attach the middleware to your router as early as possible, before route handlers. In Gin, use router.Use(AuthMiddlewareGin(publicKey)) to apply it globally, or add it to specific route groups.

Set short expiry windows and rotate refresh tokens

Access tokens should live for minutes, not hours. We typically issue 15-minute access tokens in our SaaS and e-commerce systems. If a token is compromised, the window of exposure is small. Refresh tokens—which issue new access tokens—can live longer (hours or days), but they belong in secure, httpOnly cookies, not localStorage.

When you issue a token, include the standard exp (expiration) and iat (issued at) claims. The JWT library validates exp automatically if you use RegisteredClaims:

go
package auth

import (
	"crypto/rsa"
	"fmt"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

const (
	AccessTokenTTL  = 15 * time.Minute
	RefreshTokenTTL = 7 * 24 * time.Hour
)

// IssueAccessToken creates a short-lived access token.
func IssueAccessToken(userID, email string, privateKey *rsa.PrivateKey, aud, iss string) (string, error) {
	now := time.Now()
	claims := &Claims{
		UserID: userID,
		Email:  email,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(now.Add(AccessTokenTTL)),
			IssuedAt:  jwt.NewNumericDate(now),
			Issuer:    iss,
			Audience:  jwt.ClaimStrings{aud},
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
	signed, err := token.SignedString(privateKey)
	if err != nil {
		return "", fmt.Errorf("failed to sign token: %w", err)
	}
	return signed, nil
}

// IssueRefreshToken creates a longer-lived refresh token (stored in secure cookie).
func IssueRefreshToken(userID string, privateKey *rsa.PrivateKey, iss string) (string, error) {
	now := time.Now()
	claims := &jwt.RegisteredClaims{
		Subject:   userID,
		ExpiresAt: jwt.NewNumericDate(now.Add(RefreshTokenTTL)),
		IssuedAt:  jwt.NewNumericDate(now),
		Issuer:    iss,
	}

	token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
	signed, err := token.SignedString(privateKey)
	if err != nil {
		return "", fmt.Errorf("failed to sign refresh token: %w", err)
	}
	return signed, nil
}

When the access token expires, the client exchanges the refresh token (from the secure cookie) for a new access token via a dedicated endpoint. This endpoint validates the refresh token and issues a fresh access token. Never extend an access token's TTL—issue a new one.

In a distributed system, you might also track token revocation with a Redis blacklist for critical scenarios (logout, password change), though short TTLs reduce the need. For ride-hailing or trading platforms where immediate revocation matters, invest in that infrastructure.

Validate audience and issuer claims

Most teams validate the signature and expiry, then stop. That's a mistake. If your JWT library is shared across multiple services or environments, an attacker with a valid token from Service A might try it against Service B. Audience (aud) and issuer (iss) claims prevent that.

Always set these at issuance and always validate them at verification:

go
package auth

import (
	"fmt"
)

// TokenValidator holds runtime configuration for token validation.
type TokenValidator struct {
	PublicKey     *rsa.PublicKey
	ExpectedIssuer   string
	ExpectedAudience string
}

// ValidateWithContext checks the token and its audience and issuer claims.
func (tv *TokenValidator) ValidateWithContext(raw string) (*Claims, error) {
	claims, err := ValidateToken(raw, tv.PublicKey)
	if err != nil {
		return nil, err
	}

	// Validate issuer.
	if claims.Issuer != tv.ExpectedIssuer {
		return nil, fmt.Errorf("invalid issuer: got %q, expected %q", claims.Issuer, tv.ExpectedIssuer)
	}

	// Validate audience. The audience is a list; check membership.
	if !claims.Audience.IncludesAny(tv.ExpectedAudience) {
		return nil, fmt.Errorf("invalid audience: got %v, expected %q", claims.Audience, tv.ExpectedAudience)
	}

	return claims, nil
}

Initialize the validator at startup with your service's expected issuer and audience values:

go
validator := &TokenValidator{
	PublicKey:        publicKey,
	ExpectedIssuer:   "https://auth.example.com",
	ExpectedAudience: "api.example.com",
}

// Use in middleware
claims, err := validator.ValidateWithContext(tokenString)

In a multi-tenant or microservices setup, audience becomes a distribution mechanism. Your auth service can issue tokens for multiple audiences in a single token, and each service validates only against its own audience value. This is more flexible than issuer alone and is standard in OpenID Connect flows.

Common mistakes

  • Trusting the algorithm header: Always whitelist acceptable algorithms. A downgrade attack is real.
  • Long access token TTLs: 1-hour or longer access tokens are common in legacy systems and indefensible. Use 15 minutes.
  • Storing refresh tokens in localStorage: They'll be exfiltrated via XSS. Use httpOnly, Secure, SameSite cookies.
  • Skipping audience and issuer validation: You're relying on signatures alone, which isn't enough in multi-service environments.
  • Not logging token validation failures: Security events should hit your audit log. Add structured logging with user ID, IP, and reason.
  • Using symmetric keys (HS256) for multi-service auth: Every service needs the shared secret, which is a distribution nightmare. Use RSA (RS256) instead.

Key takeaways

  • Parse JWTs with explicit algorithm whitelisting using jwt.WithValidMethods. Never trust the header.
  • Build auth middleware that extracts the bearer token and stores validated claims in context for handlers to retrieve.
  • Issue short-lived access tokens (15 minutes) and longer-lived refresh tokens in secure cookies; rotate on every refresh request.
  • Validate issuer and audience claims in addition to the signature. These claims protect against token misuse across services.
  • Log validation failures with enough context (user ID, IP, reason) for security audits and incident response.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles