JWT authentication in Go

Verify JWT tokens in Go by parsing an X.509 certificate to extract the public key and using the golang-jwt library to validate the signature.

The handshake nobody sees

You build an API. A frontend sends a request with a token in the header. Your server needs to decide in milliseconds whether to trust it. You do not want to hit a database for every single request. You want a self-contained ticket that proves the user logged in, carries their role, and expires on its own. That ticket is a JWT.

What a JWT actually is

JWT stands for JSON Web Token. It is three base64url-encoded strings glued together with dots. The first part is the header, which declares the signing algorithm and token type. The second part is the payload, which carries claims like user ID, expiration time, and roles. The third part is the signature. The signature proves nobody tampered with the header or payload after it was issued.

Think of it like a wax seal on a letter. Anyone can read the letter, but if the seal is broken or forged, you know it is not from the sender. The payload is not encrypted. Anyone with the token can decode it and read the claims. Security comes from the signature, not from hiding the data. In Go, you never write the cryptography yourself. You use the golang-jwt/jwt library to verify the seal against a public key.

JWTs are cheap to pass around. They fit in HTTP headers, they survive across domains, and they shift the verification cost from your database to a few CPU cycles. The tradeoff is that you cannot revoke a single token before it expires. You have to rely on short lifespans and a refresh flow.

Stateless authentication scales horizontally. Every node in your cluster can verify a token without talking to a central session store. That is why microservices love them. That is also why you need to understand exactly how verification works.

The minimal verification flow

Here is the simplest way to verify a JWT against an X.509 certificate. The function takes the raw token string and the PEM-encoded certificate, then returns the parsed token or an error.

package main

import (
	"crypto/x509"
	"encoding/pem"
	"fmt"
	"github.com/golang-jwt/jwt/v5"
)

// VerifyJWT checks a token string against a PEM certificate.
func VerifyJWT(tokenString string, certPEM string) (*jwt.Token, error) {
	// Decode the PEM block to extract raw certificate bytes.
	block, _ := pem.Decode([]byte(certPEM))
	if block == nil {
		return nil, fmt.Errorf("failed to decode PEM block")
	}

	// Parse the raw bytes into a standard X.509 certificate structure.
	cert, err := x509.ParseCertificate(block.Bytes)
	if err != nil {
		return nil, err
	}

	// Parse the JWT and verify the signature using the certificate's public key.
	return jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
		// Reject tokens that do not use RSA signing.
		if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
		}
		// Return the public key for signature verification.
		return cert.PublicKey, nil
	})
}

The pem.Decode call strips the -----BEGIN CERTIFICATE----- headers and returns the raw DER bytes. The _ discards the remaining PEM data after the first block. Go convention says you should acknowledge discarded values intentionally. The underscore tells the compiler you considered the second return value and chose to drop it. Use it sparingly with errors, but it is perfectly fine for unused data payloads.

The x509.ParseCertificate step converts those bytes into a structured object that exposes the public key. Finally, jwt.Parse runs the verification. It expects a callback function that returns the key to use. The callback exists so you can look up keys dynamically, support multiple keys, or reject unsupported algorithms before verification happens.

Always check if err != nil immediately after operations that can fail. The Go community accepts this boilerplate because it makes the unhappy path visible. You cannot accidentally swallow a certificate parsing failure or a signature mismatch. Trust the explicit error check. It is your safety net.

Step by step inside the verifier

When jwt.Parse runs, it splits the token string on the dots. It decodes the header and payload. It does not trust them yet. It calls your callback function first. Your callback checks the algorithm. If the token claims to use RS256 but your callback only accepts RSA methods, you return an error and the parser stops. This prevents algorithm confusion attacks where a malicious actor switches the algorithm to none or HS256.

If the algorithm passes, your callback returns cert.PublicKey. The parser takes that key, recomputes the signature over the header and payload, and compares it to the signature in the token. If they match, the token is valid. The parser then checks standard claims like expiration. If the token expired, jwt.Parse returns a *jwt.ValidationError with a specific flag. You can inspect the error flags to distinguish between a bad signature and an expired token.

The callback pattern feels verbose compared to passing a key directly. It exists because production systems rarely use a single static key. You might rotate keys, support multiple issuers, or fetch keys from a JWKS endpoint. The callback gives you a single hook to handle all of that without changing the parsing logic.

Public names start with a capital letter. Private names start lowercase. VerifyJWT is exported because other packages in your module will call it. The internal helper functions that format claims or extract headers stay lowercase. This is how Go controls visibility without keywords like public or private.

Extracting claims safely

Verification only proves the token is authentic and unexpired. It does not tell you what is inside. You need to extract the claims and validate them against your business rules. The jwt library provides a Claims interface. You define a struct that implements it, usually by embedding jwt.RegisteredClaims.

package main

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

// CustomClaims carries application-specific data alongside standard claims.
type CustomClaims struct {
	UserID string `json:"user_id"`
	Role   string `json:"role"`
	jwt.RegisteredClaims
}

Embedding jwt.RegisteredClaims gives you access to ExpiresAt, IssuedAt, Issuer, and Subject. The JSON tags map the struct fields to the payload keys. When you parse the token, you pass a pointer to your custom claims struct. The library unmarshals the payload into it.

Always validate required fields after parsing. A valid signature does not guarantee the payload contains your expected keys. Check for empty strings, missing roles, or unexpected issuers. Return early if the claims do not match your expectations.

Accept interfaces, return structs. That is the most common Go style mantra. Your verification function returns a *jwt.Token struct. Your downstream handlers accept a Claims interface or your concrete CustomClaims struct. Keep the boundaries clean.

Putting it in an HTTP handler

Verification rarely happens in isolation. It lives inside an HTTP handler or middleware. You extract the token from the Authorization header, strip the Bearer prefix, verify it, and attach the claims to the request context.

package main

import (
	"context"
	"net/http"
	"strings"
	"github.com/golang-jwt/jwt/v5"
)

// WithJWT attaches verified claims to the request context.
func WithJWT(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Extract the token from the Authorization header.
		authHeader := r.Header.Get("Authorization")
		if !strings.HasPrefix(authHeader, "Bearer ") {
			http.Error(w, "missing bearer token", http.StatusUnauthorized)
			return
		}

		tokenString := strings.TrimPrefix(authHeader, "Bearer ")
		token, err := VerifyJWT(tokenString, publicCertPEM)
		if err != nil {
			http.Error(w, "invalid token", http.StatusUnauthorized)
			return
		}

		// Attach the token to the context for downstream handlers.
		ctx := context.WithValue(r.Context(), "jwt_token", token)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

Context always goes as the first parameter in Go functions, conventionally named ctx. When you pass it through a chain of calls, every function should respect cancellation and deadlines. Here, we store the parsed token in the context so the actual handler can read the claims without re-parsing. The handler itself stays clean. It just pulls the token out of the context and checks the claims.

Do not store sensitive data in JWT payloads. The payload is base64 encoded, not encrypted. Anyone with the token can read it. Keep it to identifiers, roles, and expiration times.

Run gofmt on every file before committing. It is mandatory. Do not argue about indentation or brace placement. Let the tool decide. Most editors run it on save. Your code review should focus on logic, not formatting.

Where things break

JWT verification fails in predictable ways. The most common runtime error is a signature mismatch. This happens when the certificate does not match the private key that signed the token, or when the token was tampered with. The library returns a *jwt.ValidationError with the SignatureInvalid flag. You should log the error but never log the raw token in production. Tokens are secrets.

Clock skew causes silent failures. If your server time drifts ahead of the issuer time, valid tokens look expired. If your server time falls behind, expired tokens look valid. Always allow a small leeway window, usually five minutes, when checking expiration claims. The jwt library supports this via jwt.WithLeeway.

Algorithm confusion attacks remain a threat. If your verifier accepts HS256 but the issuer uses RS256, an attacker can forge a token using the public key as the HMAC secret. Your callback must explicitly check the signing method and reject anything unexpected. The compiler will not catch this logic error. You get a runtime verification failure or, worse, a silently accepted forged token.

If you forget to import the jwt package, the compiler rejects the program with undefined: jwt. If you pass a string where a *jwt.Token is expected, you get cannot use tokenString (untyped string constant) as *jwt.Token value in argument. These are straightforward. The tricky part is handling the error return from jwt.Parse correctly. Many developers check if err != nil but then proceed to use the token anyway. The compiler does not enforce that the token is valid after the check. You must return early on error.

Goroutine leaks happen when you spawn background verification tasks and forget to cancel them. Always pass a context with a deadline to long-running operations. The worst goroutine bug is the one that never logs.

When to reach for JWTs

Use a JWT when you need stateless authentication across multiple services and want to avoid database lookups on every request. Use an opaque session token when you need instant revocation and can afford a centralized session store. Use an API key when the client is a machine, not a human, and you do not need expiration or claims. Use mutual TLS when you are connecting internal services over a trusted network and want cryptographic identity at the transport layer. Use plain sequential code when you do not need concurrency: the simplest thing that works is usually the right thing.

Where to go next