The stateless receipt
You build an API. A client sends a request to /profile. The server needs to know which user is asking. You can't ask for a password on every request; that's terrible UX and slow. You need a way to say "I already checked this user, here's a receipt." That receipt is a JSON Web Token. It travels with every request, signed by your server, so the server can trust it without hitting the database for credentials.
The server issues the token after login. The client stores it and sends it back in the Authorization header. The server verifies the signature and expiration, then processes the request. No session store. No database lookup for auth. The token carries the trust.
Anatomy of a token
A JWT is three Base64-encoded parts glued together with dots. The header declares the signing algorithm. The payload holds the claims, like a user ID and expiration time. The signature proves the token hasn't been tampered with.
Think of it like a wax seal on an envelope. The envelope contains the message. The wax seal has your unique crest. If someone cuts the envelope open and changes the message, the seal breaks. The receiver sees the broken seal and rejects the message. If the seal is intact, the receiver trusts the message came from you. In code, the "wax seal" is a cryptographic signature generated from the header, payload, and a secret key only your server knows.
A common misconception is that JWTs hide data. They don't. Base64 encoding is reversible. Anyone with the token can decode the payload and see the user ID, roles, and expiration. The signature only proves the data hasn't changed. Never put sensitive data like passwords or credit card numbers in a JWT. If you need confidentiality, encrypt the payload separately or use a different mechanism.
The signature is the trust anchor. Verify it or don't use the token.
Parsing and verifying
Here's the core structure. You define what data lives inside the token, then write a function to parse and verify it. The github.com/golang-jwt/jwt/v5 library handles the heavy lifting.
package main
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
// Claims holds the custom data stored inside the JWT.
// Embedding RegisteredClaims gives access to standard fields like ExpiresAt.
type Claims struct {
UserID string `json:"user_id"`
jwt.RegisteredClaims
}
// ParseToken verifies the signature and expiration, then returns the claims.
// It returns nil claims if the token is invalid.
func ParseToken(tokenString string, secret []byte) (*Claims, error) {
// ParseWithClaims validates the token structure and calls the keyfunc.
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
// Verify the signing method matches expectations to prevent algorithm confusion attacks.
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return secret, nil
})
// Check for parsing errors or invalid tokens.
if err != nil || !token.Valid {
return nil, err
}
// Assert the claims type to access custom fields.
claims, ok := token.Claims.(*Claims)
if !ok {
return nil, jwt.ErrSignatureInvalid
}
return claims, nil
}
The keyfunc closure is critical. It tells the parser which secret to use and validates the signing method. If an attacker sends a token signed with a different algorithm, the keyfunc rejects it. This prevents algorithm confusion attacks where the attacker tricks the server into using a weaker or no signature.
The compiler enforces type safety here. If you pass the wrong type to ParseWithClaims, you get cannot use claims (variable of type *Claims) as jwt.Claims value in argument. JWT libraries use interfaces, so type mismatches show up at compile time. Run gofmt on your code. The Go community uses a single formatting style. Arguments about indentation or brace placement are noise. Most editors run gofmt on save automatically. Trust the tool.
Middleware and context
Here's how the middleware fits into a Gin router. It intercepts the request, validates the token, and stores the user ID in the context for downstream handlers.
package main
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
)
// contextKey is a custom type for the context key to avoid collisions.
type contextKey string
const userIDKey contextKey = "user_id"
// AuthMiddleware validates the JWT and injects the user ID into the context.
func AuthMiddleware(secret []byte) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
tokenString := authHeader
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
tokenString = authHeader[7:]
}
claims, err := ParseToken(tokenString, secret)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
// Store user ID in context for downstream handlers.
ctx := context.WithValue(c.Request.Context(), userIDKey, claims.UserID)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
Go functions that perform long-running work should accept a context.Context as the first parameter. This allows callers to cancel the operation or pass deadlines. In web handlers, the request context carries the cancellation signal when the client disconnects. Middleware should propagate this context. When you store the user ID, use context.WithValue on the request's context, then update the request object so downstream code sees the new value.
Using a custom type for the context key avoids collisions with other packages. If you use a string key like "user_id", another package might use the same string for a different purpose. A custom type guarantees uniqueness.
Here's the handler and router setup. The handler reads the user ID from the context.
// GetProfile retrieves the user ID from the context and returns it.
func GetProfile(c *gin.Context) {
userID, ok := c.Request.Context().Value(userIDKey).(string)
if !ok {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, gin.H{"user_id": userID})
}
func main() {
r := gin.Default()
secret := []byte("change-me-in-production")
// Mount middleware on the route group.
protected := r.Group("/api", AuthMiddleware(secret))
protected.GET("/profile", GetProfile)
r.Run(":8080")
}
The pattern holds across frameworks. In Echo, you use echo.Context and the echo-jwt middleware handles parsing. In Chi, you write the middleware using chi.Middleware and store data in r.Context(). The logic remains the same: extract, verify, inject. Frameworks differ in how they wrap the standard library, but the JWT verification step is framework-agnostic. You can write a pure http.Handler middleware and adapt it to any router.
Context is plumbing. Run it through every handler.
Generating tokens
Here's how to create a token. You set the claims, choose a signing method, and sign with the secret.
// GenerateToken creates a signed JWT with the given user ID and expiration.
func GenerateToken(userID string, secret []byte) (string, error) {
claims := &Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}
The ExpiresAt claim is mandatory for security. Without it, a stolen token remains valid forever. Set a reasonable expiration, like one hour for access tokens. Use refresh tokens to issue new access tokens without re-authentication. The IssuedAt claim helps detect clock skew and replay attacks.
The if err != nil check is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. If you ignore the error from SignedString, the compiler rejects the program with error declared and not used. Go forces you to handle failures explicitly.
Pitfalls and security
Secrets live in environment variables, not source code. Hardcoding a secret means anyone with access to the repository can forge tokens. Use a secrets manager or environment variables in production. Rotate secrets periodically. If a key is compromised, old tokens remain valid until they expire. Use short expiration times to limit the blast radius.
Check the Issuer and Audience claims if your token flows between multiple services. The Issuer identifies who created the token. The Audience identifies who should accept it. Verifying these claims ensures the token is intended for your service and prevents cross-service token misuse.
The jwt library returns specific error types. jwt.ErrTokenExpired indicates the token is past its deadline. jwt.ErrSignatureInvalid means the signature check failed. You can type assert the error to provide specific feedback, though returning a generic unauthorized response is safer to avoid leaking implementation details.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. In web servers, the request context provides cancellation. If a handler spawns a goroutine, pass the context and check for cancellation to avoid leaking resources.
Stateless auth scales horizontally. Session cookies scale vertically. Pick the shape of your deployment.
When to use JWTs
Use JWTs when you need stateless authentication across multiple services or microservices. Use session cookies when your app is a single server and you want automatic CSRF protection with browser handling. Use API keys when the client is a server-to-server service and you don't need user-specific claims. Use OAuth2 tokens when you need to delegate authentication to a third-party provider like Google or GitHub.