Salta al contenuto principale

JWT Authentication in Go: HS256, RS256 e Pattern di Middleware

·12 minuti
Indice dei contenuti
I JWT non sono sessioni. Sono claim firmati e auto-contenuti che non possono essere revocati senza infrastruttura aggiuntiva. Capire questo tradeoff prima di scegliere JWT e’ piu’ importante di qualsiasi dettaglio implementativo. Questo post copre HS256 vs RS256, middleware di validazione corretto, revoca dei token con Redis e un endpoint JWKS per la verifica service-to-service.

I JWT sono ampiamente mal utilizzati. I team li usano per default perche’ ogni tutorial li mostra, non perche’ siano lo strumento giusto per il lavoro. Prima di scrivere codice, bisogna capire cosa sono davvero i JWT e cosa non sono.

Un JWT e’ un token firmato contenente claim. La firma permette al titolare di verificare che il token sia stato emesso da qualcuno con la chiave di firma. Non fornisce cifratura (a meno di usare JWE). Non e’ una sessione: non puoi invalidarlo server-side senza mantenere uno stato, il che vanifica il pitch “stateless”. Non e’ intrinsecamente sicuro: l’attacco alg: none e HS256 con segreti condivisi tra servizi hanno causato reali incidenti in produzione.

Per le applicazioni web basate su browser, le sessioni cookie con storage server-side sono spesso la scelta migliore. I JWT brillano nell’autenticazione service-to-service e nell’accesso alle API dove la statelessness e’ un requisito di progettazione genuino.

HS256 vs RS256
#

flowchart LR
    subgraph HS256
        A[Service A] -->|segreto condiviso| B[Service B]
        A -->|segreto condiviso| C[Service C]
    end
    subgraph RS256
        I[Issuer\nchiave privata] -->|firma| T[Token]
        T -->|verifica con chiave pubblica| D[Service D]
        T -->|verifica con chiave pubblica| E[Service E]
        I -->|JWKS endpoint| D
        I -->|JWKS endpoint| E
    end

HS256 (HMAC-SHA256) usa una singola chiave segreta sia per la firma che per la verifica. Qualsiasi servizio che verifica il token deve avere il segreto, il che significa che il segreto deve essere distribuito a tutti i verificatori. La compromissione di qualsiasi servizio compromette la capacita’ di firma.

RS256 (RSA-SHA256) usa una coppia di chiavi asimmetrica. L’emittente firma con la chiave privata; i verificatori hanno bisogno solo della chiave pubblica. La chiave privata non lascia mai l’emittente. Puoi pubblicare la chiave pubblica su un endpoint JWKS e permettere agli altri servizi di recuperarla.

  • Autenticazione a servizio singolo dove l’emittente e il verificatore sono lo stesso servizio.
  • Monolite interno o API gateway che emette e valida token.
  • Il segreto e’ memorizzato in un secrets manager (non hardcoded o in variabili d’ambiente).

Non usare mai HS256 quando piu’ servizi distinti devono verificare i token.

  • Piu’ servizi indipendenti devono verificare i token senza condividere un segreto.
  • Gestisci un authorization server (OAuth2, OIDC) che emette token a terze parti.
  • Vuoi un endpoint JWKS pubblico cosi’ i verificatori possono ruotare le chiavi pubbliche automaticamente.
  • I requisiti di conformita’ richiedono non-ripudio (solo il titolare della chiave privata potrebbe aver firmato).

HS256: Caricamento Corretto della Chiave e Validazione
#

internal/auth/hs256.go
package auth

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

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

// Claims contiene il nostro payload personalizzato insieme ai registered claim standard.
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 carica il segreto da una variabile d'ambiente.
// Non accettare mai il segreto come argomento stringa da un chiamante -- invita all'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("la variabile d'ambiente %q deve essere lunga almeno 32 byte per 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: univoco per token, usato per la revoca
            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) {
            // Controlla sempre l'algoritmo. Un attaccante puo' creare un token con
            // alg: none o alg: RS256 e passare la chiave pubblica come segreto HMAC.
            if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, fmt.Errorf("metodo di firma inatteso: %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("claim del token non validi")
    }
    return claims, nil
}
Avviso

Il controllo dell’algoritmo nella Keyfunc non e’ opzionale. La vulnerabilita’ alg: none permette a un attaccante di rimuovere completamente la firma e costruire un payload arbitrario che alcune implementazioni accettano. L’opzione jwt.WithExpirationRequired() garantisce che i token senza claim exp vengano rifiutati.

RS256: Coppia di Chiavi RSA, Firma e Verifica
#

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 genera una coppia di chiavi RSA a 2048 bit e scrive i file PEM.
// Eseguilo una volta durante il setup; memorizza la chiave privata in un 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 // claim kid, usato nel 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("impossibile decodificare il blocco PEM")
    }
    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 // includi il key ID per la ricerca nel JWKS
    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("metodo di firma inatteso: %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("claim del token non validi")
    }
    return claims, nil
}

Claim Standard: A Cosa Serve Ciascuno
#

ClaimNomeScopo
issIssuerChi ha emesso il token. Validalo per prevenire attacchi di sostituzione token.
subSubjectIl principal che il token rappresenta (di solito l’ID utente).
audAudiencePer quale servizio e’ destinato il token. Valida per prevenire il riutilizzo tra servizi.
expExpirationTimestamp Unix dopo il quale il token non e’ valido. Impostalo sempre.
iatIssued AtQuando e’ stato emesso il token. Utile per rilevare token emessi prima di una rotazione della chiave.
jtiJWT IDIdentificatore univoco per token. Necessario per la revoca (blocklist per JTI).
Importante

Il claim aud e’ critico nelle architetture multi-servizio. Se il servizio A e il servizio B usano entrambi lo stesso issuer, un token emesso per il servizio A non deve essere accettato dal servizio B. Valida il claim aud ad ogni verifica.

Middleware di Validazione per net/http
#

internal/auth/middleware.go
package auth

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

type contextKey string

const claimsKey contextKey = "jwt_claims"

// Validator e' implementato sia da HS256Issuer che da RS256Issuer.
type Validator interface {
    Validate(tokenString string) (*Claims, error)
}

// Middleware estrae il token Bearer, lo valida e inietta i claim nel context.
// In caso di fallimento restituisce 401 e non chiama il prossimo 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, "Authorization header mancante", http.StatusUnauthorized)
                return
            }

            parts := strings.SplitN(authHeader, " ", 2)
            if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
                http.Error(w, "formato Authorization header non valido", http.StatusUnauthorized)
                return
            }

            claims, err := v.Validate(parts[1])
            if err != nil {
                // Non divulgare i dettagli dell'errore di validazione al client.
                http.Error(w, "unauthorized", http.StatusUnauthorized)
                return
            }

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

// ClaimsFromContext recupera i claim validati dal context della richiesta.
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
    claims, ok := ctx.Value(claimsKey).(*Claims)
    return claims, ok
}

Utilizzo con il mux standard net/http:

cmd/server/main.go
package main

import (
    "encoding/json"
    "net/http"
    "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()

    // Endpoint pubblico: emette token dopo la verifica delle credenziali.
    mux.HandleFunc("POST /auth/token", func(w http.ResponseWriter, r *http.Request) {
        // Verifica credenziali... (omesso)
        token, err := issuer.Issue("user-123", []string{"reader"})
        if err != nil {
            http.Error(w, "errore interno", http.StatusInternalServerError)
            return
        }
        json.NewEncoder(w).Encode(map[string]string{"token": token})
    })

    // Endpoint protetto: il middleware valida il 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)
}

Revoca dei Token con Redis
#

I JWT sono stateless per design; la revoca richiede l’aggiunta di stato. L’approccio standard e’ una JTI blocklist: memorizza il jti dei token revocati in Redis con un TTL corrispondente all’exp del token. I token scadono da Redis automaticamente quando sarebbero comunque scaduti.

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 aggiunge il JTI di un token alla blocklist fino alla sua scadenza.
func (b *Blocklist) Revoke(ctx context.Context, claims *Claims) error {
    ttl := time.Until(claims.ExpiresAt.Time)
    if ttl <= 0 {
        return nil // gia' scaduto, non necessario memorizzarlo
    }
    key := fmt.Sprintf("blocklist:jti:%s", claims.ID)
    return b.rdb.Set(ctx, key, "1", ttl).Err()
}

// IsRevoked restituisce true se il JTI e' nella 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
}

Estendi il middleware per controllare la 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))
        })
    }
}
Nota

Il controllo della blocklist aggiunge un round-trip Redis per richiesta. Per API ad alto traffico, considera un bloom filter in-memory locale come controllo di primo livello, ricorrendo a Redis solo per i potenziali positivi. Questo riduce il carico su Redis al costo di un piccolo tasso di falsi positivi (che portano sempre a un controllo Redis completo, non a una revoca falsa).

Endpoint JWKS RS256
#

Un endpoint JWKS (JSON Web Key Set) permette agli altri servizi di recuperare la chiave pubblica senza distribuzione manuale. Questo e’ il pattern standard in OAuth2 e 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"` // modulo codificato in base64url
    E   string `json:"e"` // esponente codificato in base64url
}

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

// JWKSHandler serve la chiave pubblica in formato JWKS.
// Montalo su /.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)
    }
}

Gli altri servizi possono usare jwx o recuperare il JWKS e verificare i token senza conoscere la chiave privata:

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))
}

Errori Comuni
#

HS256 con segreti condivisi nei microservizi
Se usi HS256 e tre servizi devono verificare i token, tutti e tre devono avere il segreto. La compromissione di uno qualsiasi di quei servizi permette a un attaccante di falsificare token per tutti i servizi. Usa RS256 con un endpoint JWKS quando piu’ di un servizio verifica i token.
Accettare token senza validazione exp
Un token senza un claim di scadenza e’ valido per sempre. La libreria golang-jwt/jwt non impone la scadenza per default in tutte le versioni. Passa sempre jwt.WithExpirationRequired() al tuo parser. Controlla ogni percorso di validazione dei token nel tuo codebase.
Usare JWT per lo stato di sessione delle web application
I JWT memorizzati in localStorage sono accessibili a JavaScript e vulnerabili agli XSS. Le sessioni cookie con HttpOnly, Secure e SameSite=Strict sono piu’ sicure per le web app. I JWT hanno senso per i client API (app mobile, CLI, service-to-service) dove i cookie non sono applicabili.
Hardcoding dei segreti o caricamento da file env in chiaro
La chiave segreta per HS256 o il percorso della chiave privata per RS256 devono provenire da un secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager) o da una variabile d’ambiente iniettata dalla piattaforma di deployment. Non committare mai materiale di chiave nel source control o includerlo nelle immagini Docker.
Non validare il claim aud
Se il tuo auth server emette token per piu’ servizi e non validi aud, un token emesso per il servizio A e’ valido anche per il servizio B. Questo viola il principio del minimo privilegio e puo’ essere sfruttato se il servizio B ha privilegi piu’ elevati del servizio A.

Se vuoi approfondire uno di questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architettura cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.

Articoli correlati