The stateless login
You build a login endpoint. The user types a password. The server checks the database and confirms the credentials. Now the user requests /profile. The server asks, "Who are you?"
If you store a session ID in a Redis cache, the server remembers. The client sends the ID. The server looks it up. This works, but it ties the client to a specific server or cache cluster. If you scale out to three servers, the cache must be shared. If the cache goes down, no one can log in.
JWTs flip the model. The client remembers. The server signs a token and hands it to the client. The client stores the token and sends it back on every request. The server verifies the signature. No database lookup. No shared cache. The token carries the identity.
What a JWT actually is
JWT stands for JSON Web Token. It is a compact string of three parts separated by dots. The header declares the algorithm used for signing. The payload holds the claims, like user ID and expiration time. The signature proves the token came from the server and wasn't tampered with.
The header and payload are Base64url encoded JSON. Base64url is not encryption. Anyone with the token can decode the payload and read the claims. Do not put passwords or sensitive secrets in a JWT. The signature is the only protection. If you need confidentiality, encrypt the token or rely on TLS to protect the transport.
The signature is generated by hashing the header and payload with a secret key. The library uses HMAC-SHA256 by default. The server keeps the secret key. Anyone with the secret can verify the token. If the signature matches, the token is valid. If the payload changes, the signature breaks.
Generating a token
Here is the simplest way to create a JWT. You define the claims, choose the signing method, and sign the token with a secret key.
package main
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
// secretKey must be a strong random string, never hardcoded in production
var secretKey = []byte("super-secret-key-change-me")
// GenerateToken creates a signed JWT for the given user ID
func GenerateToken(userID string) (string, error) {
// Claims hold the data inside the token
claims := jwt.MapClaims{
"user_id": userID,
// Set expiration to 24 hours from now
"exp": time.Now().Add(24 * time.Hour).Unix(),
}
// HS256 uses HMAC with SHA-256 hashing
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Sign the token and return the string representation
return token.SignedString(secretKey)
}
The MapClaims type is a dictionary. Keys are strings, values are interface types. The library handles the JSON encoding. The exp claim is standard. The library checks it automatically during parsing. If the current time is past the expiration, the token is invalid.
The SignedString method returns the three-part string. You send this string to the client, usually in the HTTP response body or as a cookie. The client stores it and sends it back in the Authorization header.
Verifying the token in middleware
Real applications need to verify the token on every request. Middleware is the standard pattern in Go. You wrap the next handler with a function that checks the token. If the token is valid, the middleware passes the request downstream. If the token is invalid, the middleware returns an error.
Here is the extraction and parsing logic. It reads the header, strips the prefix, and verifies the signature.
package main
import (
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
// ParseToken extracts and validates the JWT from the request header
func ParseToken(r *http.Request) (*jwt.Token, error) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return nil, fmt.Errorf("missing authorization header")
}
// Strip the "Bearer " prefix
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
return nil, fmt.Errorf("invalid authorization format")
}
// Parse verifies signature and expiration
return jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Reject unexpected algorithms to prevent confusion attacks
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return secretKey, nil
})
}
The Parse function takes a callback. The callback returns the key used for verification. The library calls the callback with the token. You check the signing method inside the callback. This prevents algorithm confusion attacks. Attackers can change the algorithm to none. The library might skip verification if you don't check. The code checks SigningMethodHMAC. This blocks the attack.
Here is the middleware that uses the parser. It extracts the claims and passes the user ID to the next handler via context.
package main
import (
"context"
"net/http"
"github.com/golang-jwt/jwt/v5"
)
// AuthMiddleware wraps the next handler with JWT verification
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, err := ParseToken(r)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
// Extract claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "invalid claims", http.StatusUnauthorized)
return
}
// Pass user ID to downstream handlers via context
ctx := context.WithValue(r.Context(), "user_id", claims["user_id"])
next.ServeHTTP(w, r.WithContext(ctx))
})
}
The middleware returns an http.Handler. The http.HandlerFunc adapts the function to the interface. The function checks the token. If the token is invalid, it returns 401 Unauthorized. If the token is valid, it extracts the claims. The type assertion token.Claims.(jwt.MapClaims) converts the interface to the map. If the assertion fails, the claims are malformed.
The middleware attaches the user ID to the context. context.WithValue creates a new context with the value. The modified request is passed to the next handler. Downstream handlers can read the user ID from the context. This avoids passing the user ID as a parameter through every function.
Conventions and details
Go has strong conventions for context and error handling. context.Context always goes as the first parameter. Functions that take a context should respect cancellation and deadlines. In middleware, you attach values to the context and pass the modified request downstream. This keeps the data flow clean.
Error handling is verbose by design. if err != nil makes the unhappy path visible. The community accepts the boilerplate. You check the error immediately. You return early. This prevents silent failures.
The receiver name is usually one or two letters matching the type. Middleware functions don't use receivers, but if you wrap a struct, use (m *Middleware) ServeHTTP(...). Not (this *Middleware).
gofmt is mandatory. Don't argue about indentation. Let the tool decide. Most editors run it on save. Your code matches the rest of the ecosystem.
Pitfalls and compiler errors
JWTs are simple, but they have traps. The secret key is the most critical part. If you leak the key, anyone can forge tokens. Store the key in environment variables or a secrets manager. Never commit it to version control.
Expiration is not automatic. You must set the exp claim. If you forget, the token never expires. The library does not enforce expiration during generation. It only checks it during parsing. Set a reasonable expiration time. Use refresh tokens for long-lived sessions.
Algorithm confusion is a real attack. Attackers can change the algorithm to none. The library might skip verification. You must check the signing method in the parse callback. The code checks SigningMethodHMAC. This blocks the attack.
The compiler catches type errors early. If you forget to define secretKey, the compiler rejects the program with undefined: secretKey. If you pass the wrong type to SignedString, the compiler complains with cannot use ... as ... in argument. If you import a package and don't use it, the compiler rejects the file with imported and not used. Fix these errors before running the code.
Runtime panics happen when you ignore errors. If you skip the if err != nil check, the program might panic when accessing a nil token. Always check the error. Return early. This keeps the program stable.
When to use JWTs
Use a JWT when you need stateless authentication across multiple services. The token carries the identity. Any service with the secret key can verify it. No shared database required.
Use server-side sessions when you need immediate revocation or store large user data. Sessions live on the server. You can invalidate them instantly. JWTs stay valid until expiration. You need a blacklist to revoke them.
Use API keys when the client is a server or script, not a human user. API keys are static. JWTs expire. Scripts don't need expiration.
Use OAuth2 when you want users to log in with a third-party provider. OAuth2 delegates authentication. JWTs handle the token format. They often work together.
JWTs are stateless. The client holds the truth.