Skip to main content

JWT Authentication in Go: HS256, RS256, and Middleware Patterns

·12 mins
Table of Contents
JWTs are not sessions. They are signed, self-contained claims that cannot be revoked without additional infrastructure. Understanding that tradeoff before you reach for JWT is more important than any implementation detail. This post covers HS256 vs RS256, correct validation middleware, token revocation with Redis, and a JWKS endpoint for service-to-service verification.

JWTs are widely misused. Teams reach for them by default because every tutorial shows them, not because they are the right tool for the job. Before writing any code, you need to understand what JWTs actually are and what they are not.

A JWT is a signed token containing claims. The signature lets the holder verify the token was issued by someone with the signing key. It does not provide encryption (unless you use JWE). It is not a session: you cannot invalidate it server-side without maintaining state, which defeats the “stateless” pitch. It is not inherently secure: the alg: none attack and HS256-with-shared-secrets-across-services have caused real production incidents.

For browser-based web applications, cookie sessions with server-side session storage are often the better choice. JWTs shine in service-to-service authentication and API access where statelessness is a genuine design requirement.

HS256 vs RS256
#

flowchart LR
    subgraph HS256
        A[Service A] -->|shared secret| B[Service B]
        A -->|shared secret| C[Service C]
    end
    subgraph RS256
        I[Issuer\nprivate key] -->|sign| T[Token]
        T -->|verify with public key| D[Service D]
        T -->|verify with public key| E[Service E]
        I -->|JWKS endpoint| D
        I -->|JWKS endpoint| E
    end

HS256 (HMAC-SHA256) uses a single secret key for both signing and verification. Any service that verifies the token must have the secret, which means the secret must be distributed to all verifiers. A compromise of any service compromises the signing capability.

RS256 (RSA-SHA256) uses an asymmetric key pair. The issuer signs with the private key; verifiers only need the public key. The private key never leaves the issuer. You can publish the public key at a JWKS endpoint and let other services fetch it.

  • Single-service authentication where the issuer and verifier are the same service.
  • Internal monolith or API gateway that both issues and validates tokens.
  • The secret is stored in a secrets manager (not hardcoded or in env vars).

Never use HS256 when multiple distinct services need to verify tokens.

  • Multiple independent services need to verify tokens without sharing a secret.
  • You operate an authorization server (OAuth2, OIDC) issuing tokens to third parties.
  • You want a public JWKS endpoint so verifiers can rotate public keys automatically.
  • Compliance requirements demand non-repudiation (only the private key holder could have signed).

HS256: Correct Key Loading and Validation
#

internal/auth/hs256.go
package auth

import (
    "errors"
    "fmt"
    "os"
    "time"

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

// Claims holds our custom payload alongside standard registered claims.
type Claims struct {
    UserID string   `json:"uid"`
    Roles  []string `json:"roles"`
    jwt.RegisteredClaims
}

type HS256Issuer struct {
    secret   []byte
    issuer   string
    audience []string
    ttl      time.Duration
}

// NewHS256Issuer loads the secret from an environment variable.
// Never accept the secret as a string argument from a caller -- it invites hardcoding.
func NewHS256Issuer(envVar, issuer string, audience []string, ttl time.Duration) (*HS256Issuer, error) {
    secret := os.Getenv(envVar)
    if len(secret) < 32 {
        return nil, fmt.Errorf("env var %q must be at least 32 bytes for HS256", envVar)
    }
    return &HS256Issuer{
        secret:   []byte(secret),
        issuer:   issuer,
        audience: audience,
        ttl:      ttl,
    }, nil
}

func (i *HS256Issuer) Issue(userID string, roles []string) (string, error) {
    now := time.Now()
    claims := Claims{
        UserID: userID,
        Roles:  roles,
        RegisteredClaims: jwt.RegisteredClaims{
            ID:        uuid.NewString(), // jti: unique per token, used for revocation
            Issuer:    i.issuer,
            Audience:  i.audience,
            Subject:   userID,
            IssuedAt:  jwt.NewNumericDate(now),
            NotBefore: jwt.NewNumericDate(now),
            ExpiresAt: jwt.NewNumericDate(now.Add(i.ttl)),
        },
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(i.secret)
}

func (i *HS256Issuer) Validate(tokenString string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(
        tokenString,
        &Claims{},
        func(t *jwt.Token) (interface{}, error) {
            // Always check the algorithm. An attacker can craft a token with
            // alg: none or alg: RS256 and pass the public key as the HMAC secret.
            if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
            }
            return i.secret, nil
        },
        jwt.WithIssuer(i.issuer),
        jwt.WithAudience(i.audience[0]),
        jwt.WithExpirationRequired(),
    )
    if err != nil {
        return nil, fmt.Errorf("validate token: %w", err)
    }
    claims, ok := token.Claims.(*Claims)
    if !ok || !token.Valid {
        return nil, errors.New("invalid token claims")
    }
    return claims, nil
}
Warning

The algorithm check in the Keyfunc is not optional. The alg: none vulnerability allows an attacker to strip the signature entirely and construct an arbitrary payload that some implementations accept. The jwt.WithExpirationRequired() option ensures tokens without an exp claim are rejected, which they should be unless you have a specific reason.

RS256: RSA Key Pair, Signing, and Verification
#

internal/auth/rs256.go
package auth

import (
    "crypto/rand"
    "crypto/rsa"
    "crypto/x509"
    "encoding/pem"
    "fmt"
    "os"
    "time"

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

// GenerateRSAKeyPair generates a 2048-bit RSA key pair and writes PEM files.
// Run this once during setup; store the private key in a secrets manager.
func GenerateRSAKeyPair(privateKeyPath, publicKeyPath string) error {
    privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
    if err != nil {
        return fmt.Errorf("generate rsa key: %w", err)
    }

    privPEM := pem.EncodeToMemory(&pem.Block{
        Type:  "RSA PRIVATE KEY",
        Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
    })
    if err := os.WriteFile(privateKeyPath, privPEM, 0600); err != nil {
        return fmt.Errorf("write private key: %w", err)
    }

    pubDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
    if err != nil {
        return fmt.Errorf("marshal public key: %w", err)
    }
    pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})
    return os.WriteFile(publicKeyPath, pubPEM, 0644)
}

type RS256Issuer struct {
    privateKey *rsa.PrivateKey
    publicKey  *rsa.PublicKey
    keyID      string // kid claim, used in JWKS
    issuer     string
    ttl        time.Duration
}

func NewRS256Issuer(privateKeyPath, keyID, issuer string, ttl time.Duration) (*RS256Issuer, error) {
    pemData, err := os.ReadFile(privateKeyPath)
    if err != nil {
        return nil, fmt.Errorf("read private key: %w", err)
    }
    block, _ := pem.Decode(pemData)
    if block == nil {
        return nil, fmt.Errorf("failed to decode PEM block")
    }
    privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
    if err != nil {
        return nil, fmt.Errorf("parse private key: %w", err)
    }
    return &RS256Issuer{
        privateKey: privateKey,
        publicKey:  &privateKey.PublicKey,
        keyID:      keyID,
        issuer:     issuer,
        ttl:        ttl,
    }, nil
}

func (i *RS256Issuer) Issue(userID string, roles []string) (string, error) {
    now := time.Now()
    claims := Claims{
        UserID: userID,
        Roles:  roles,
        RegisteredClaims: jwt.RegisteredClaims{
            ID:        uuid.NewString(),
            Issuer:    i.issuer,
            Subject:   userID,
            IssuedAt:  jwt.NewNumericDate(now),
            NotBefore: jwt.NewNumericDate(now),
            ExpiresAt: jwt.NewNumericDate(now.Add(i.ttl)),
        },
    }
    token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
    token.Header["kid"] = i.keyID // include key ID for JWKS lookup
    return token.SignedString(i.privateKey)
}

func (i *RS256Issuer) Validate(tokenString string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(
        tokenString,
        &Claims{},
        func(t *jwt.Token) (interface{}, error) {
            if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
                return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
            }
            return i.publicKey, nil
        },
        jwt.WithIssuer(i.issuer),
        jwt.WithExpirationRequired(),
    )
    if err != nil {
        return nil, fmt.Errorf("validate rs256 token: %w", err)
    }
    claims, ok := token.Claims.(*Claims)
    if !ok || !token.Valid {
        return nil, fmt.Errorf("invalid token claims")
    }
    return claims, nil
}

Standard Claims: What Each Is For
#

ClaimNamePurpose
issIssuerWho issued the token. Validate this to prevent token substitution attacks.
subSubjectThe principal the token represents (usually user ID).
audAudienceWhich service the token is intended for. Validate to prevent token reuse across services.
expExpirationUnix timestamp after which the token is invalid. Always set this.
iatIssued AtWhen the token was issued. Useful for detecting tokens issued before a key rotation.
jtiJWT IDUnique identifier per token. Required for revocation (blocklist by JTI).
Important

The aud claim is critical in multi-service architectures. If service A and service B both use the same issuer, a token issued for service A must not be accepted by service B. Validate the aud claim on every verification.

Validation Middleware for net/http
#

internal/auth/middleware.go
package auth

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

type contextKey string

const claimsKey contextKey = "jwt_claims"

// Validator is implemented by both HS256Issuer and RS256Issuer.
type Validator interface {
    Validate(tokenString string) (*Claims, error)
}

// Middleware extracts the Bearer token, validates it, and injects claims into context.
// On failure it returns 401 and does not call the next handler.
func Middleware(v Validator) 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 || !strings.EqualFold(parts[0], "Bearer") {
                http.Error(w, "invalid Authorization header format", http.StatusUnauthorized)
                return
            }

            claims, err := v.Validate(parts[1])
            if err != nil {
                // Do not leak validation error details to the client.
                http.Error(w, "unauthorized", http.StatusUnauthorized)
                return
            }

            ctx := context.WithValue(r.Context(), claimsKey, claims)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

// ClaimsFromContext retrieves validated claims from the request context.
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
    claims, ok := ctx.Value(claimsKey).(*Claims)
    return claims, ok
}

Usage with the standard net/http mux:

cmd/server/main.go
package main

import (
    "encoding/json"
    "net/http"
    "os"
    "time"

    "yourmodule/internal/auth"
)

func main() {
    issuer, err := auth.NewHS256Issuer("JWT_SECRET", "auth-service", []string{"api"}, time.Hour)
    if err != nil {
        panic(err)
    }

    mux := http.NewServeMux()

    // Public endpoint: issue token after credential verification.
    mux.HandleFunc("POST /auth/token", func(w http.ResponseWriter, r *http.Request) {
        // Verify credentials... (omitted)
        token, err := issuer.Issue("user-123", []string{"reader"})
        if err != nil {
            http.Error(w, "internal error", http.StatusInternalServerError)
            return
        }
        json.NewEncoder(w).Encode(map[string]string{"token": token})
    })

    // Protected endpoint: middleware validates the token.
    protected := http.NewServeMux()
    protected.HandleFunc("GET /api/profile", func(w http.ResponseWriter, r *http.Request) {
        claims, _ := auth.ClaimsFromContext(r.Context())
        json.NewEncoder(w).Encode(map[string]string{"user_id": claims.UserID})
    })

    mux.Handle("/api/", auth.Middleware(issuer)(protected))

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

Token Revocation with Redis
#

JWTs are stateless by design; revocation requires adding state. The standard approach is a JTI blocklist: store the jti of revoked tokens in Redis with a TTL matching the token’s exp. Tokens expire from Redis automatically when they would have expired anyway.

internal/auth/revocation.go
package auth

import (
    "context"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

type Blocklist struct {
    rdb *redis.Client
}

func NewBlocklist(rdb *redis.Client) *Blocklist {
    return &Blocklist{rdb: rdb}
}

// Revoke adds a token's JTI to the blocklist until its expiration.
func (b *Blocklist) Revoke(ctx context.Context, claims *Claims) error {
    ttl := time.Until(claims.ExpiresAt.Time)
    if ttl <= 0 {
        return nil // already expired, no need to store
    }
    key := fmt.Sprintf("blocklist:jti:%s", claims.ID)
    return b.rdb.Set(ctx, key, "1", ttl).Err()
}

// IsRevoked returns true if the JTI is in the blocklist.
func (b *Blocklist) IsRevoked(ctx context.Context, jti string) (bool, error) {
    exists, err := b.rdb.Exists(ctx, fmt.Sprintf("blocklist:jti:%s", jti)).Result()
    if err != nil {
        return false, fmt.Errorf("blocklist check: %w", err)
    }
    return exists > 0, nil
}

Extend the middleware to check the blocklist:

internal/auth/middleware_with_revocation.go
package auth

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

func MiddlewareWithRevocation(v Validator, bl *Blocklist) 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")
            parts := strings.SplitN(authHeader, " ", 2)
            if len(parts) != 2 {
                http.Error(w, "unauthorized", http.StatusUnauthorized)
                return
            }

            claims, err := v.Validate(parts[1])
            if err != nil {
                http.Error(w, "unauthorized", http.StatusUnauthorized)
                return
            }

            revoked, err := bl.IsRevoked(r.Context(), claims.ID)
            if err != nil || revoked {
                http.Error(w, "unauthorized", http.StatusUnauthorized)
                return
            }

            ctx := context.WithValue(r.Context(), claimsKey, claims)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}
Note

The blocklist check adds one Redis roundtrip per request. For high-traffic APIs, consider a local in-memory bloom filter as a first-pass check, falling back to Redis only for potential positives. This reduces Redis load at the cost of a small false-positive rate (which always results in a full Redis check, not a false revocation).

RS256 JWKS Endpoint
#

A JWKS (JSON Web Key Set) endpoint lets other services fetch the public key without manual distribution. This is the standard pattern in OAuth2 and OIDC.

internal/auth/jwks.go
package auth

import (
    "crypto/rsa"
    "encoding/base64"
    "encoding/json"
    "math/big"
    "net/http"
)

type jwk struct {
    Kty string `json:"kty"`
    Use string `json:"use"`
    Kid string `json:"kid"`
    Alg string `json:"alg"`
    N   string `json:"n"` // base64url-encoded modulus
    E   string `json:"e"` // base64url-encoded exponent
}

type jwks struct {
    Keys []jwk `json:"keys"`
}

// JWKSHandler serves the public key in JWKS format.
// Mount this at /.well-known/jwks.json.
func JWKSHandler(publicKey *rsa.PublicKey, keyID string) http.HandlerFunc {
    key := jwk{
        Kty: "RSA",
        Use: "sig",
        Kid: keyID,
        Alg: "RS256",
        N:   base64.RawURLEncoding.EncodeToString(publicKey.N.Bytes()),
        E:   base64.RawURLEncoding.EncodeToString(big.NewInt(int64(publicKey.E)).Bytes()),
    }
    body, _ := json.Marshal(jwks{Keys: []jwk{key}})

    return func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.Header().Set("Cache-Control", "public, max-age=3600")
        w.Write(body)
    }
}

Other services can use jwx or fetch the JWKS and verify tokens without knowing the private key:

internal/auth/remote_verifier.go
package auth

import (
    "context"
    "fmt"

    "github.com/lestrrat-go/jwx/v2/jwk"
    "github.com/lestrrat-go/jwx/v2/jwt"
)

type RemoteVerifier struct {
    cache *jwk.Cache
    jwksURL string
}

func NewRemoteVerifier(ctx context.Context, jwksURL string) (*RemoteVerifier, error) {
    cache := jwk.NewCache(ctx)
    if err := cache.Register(jwksURL); err != nil {
        return nil, fmt.Errorf("register jwks: %w", err)
    }
    return &RemoteVerifier{cache: cache, jwksURL: jwksURL}, nil
}

func (v *RemoteVerifier) Verify(ctx context.Context, tokenBytes []byte) (jwt.Token, error) {
    keySet, err := v.cache.Get(ctx, v.jwksURL)
    if err != nil {
        return nil, fmt.Errorf("fetch jwks: %w", err)
    }
    return jwt.Parse(tokenBytes, jwt.WithKeySet(keySet), jwt.WithValidate(true))
}

Common Mistakes
#

HS256 with shared secrets in microservices
If you use HS256 and three services need to verify tokens, all three must have the secret. Any one of those services being compromised lets an attacker forge tokens for all services. Use RS256 with a JWKS endpoint when more than one service verifies tokens.
Accepting tokens without exp validation
A token without an expiration claim is valid forever. The golang-jwt/jwt library does not enforce expiration by default in all versions. Always pass jwt.WithExpirationRequired() to your parser. Audit every token validation path in your codebase.
Using JWT for web application session state
JWTs stored in localStorage are accessible to JavaScript and vulnerable to XSS. Cookie sessions with HttpOnly, Secure, and SameSite=Strict are safer for web apps. JWTs make sense for API clients (mobile apps, CLIs, service-to-service) where cookies are not applicable.
Hardcoding secrets or loading them from plaintext env files
The secret key for HS256 or the private key path for RS256 must come from a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager) or from an environment variable injected by your deployment platform. Never commit key material to source control or bake it into Docker images.
Not validating the aud claim
If your auth server issues tokens for multiple services and you do not validate aud, a token issued for service A is also valid for service B. This violates the principle of least privilege and can be exploited if service B has higher privileges than service A.

If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.

Related