In the world of modern web development, JSON Web Tokens (JWT) have become the gold standard for handling authentication and secure data exchange. But what exactly happens when a server issues a token? Is it encrypted? Can anyone read it?
This guide breaks down the mechanics of JWTs, clarifies the common confusion between “encoding” and “encryption,” and shows you how to implement them in Go (Golang).
A JWT is a compact, URL-safe means of representing claims to be transferred between two parties. Unlike traditional session-based authentication where the server keeps a record of logged-in users (stateful), JWTs are stateless. The token itself contains all the necessary information to verify the user.
If you look at a JWT, it appears as a long string of random characters separated by two dots. It follows this structure:
Header.Payload.Signature
HS256) and the token type (JWT).A common question developers ask is: “If the token is just Base64 encoded, can’t anyone read the signature and the payload?”
The answer is yes. Anyone who intercepts the token can decode and read the payload. However, the security of a JWT doesn’t come from hiding the data; it comes from ensuring the data hasn’t been changed.
Think of a JWT like a letter sent by a King (the Server) to a Messenger (the Client).
Anyone can see the wax seal. It isn’t hidden. But no one can copy or forge the seal because they don’t have the King’s signet ring (the Secret Key).
If a thief changes the letter to say “Give this messenger 10,000 gold coins,” the wax seal will no longer match the content. They cannot “re-seal” the letter because they lack the King’s ring.
The signature is not plain text, nor is it encrypted. It is a binary cryptographic hash that is Base64Url encoded.
The Math: The server takes the Header and Payload and runs them through a hashing algorithm (like HMAC-SHA256) using a Secret Key.
$$Signature = HMACSHA256(Header + “.” + Payload, Secret_Key)$$
The Encoding: The output of this hash is raw binary data (bytes). To make it safe for URLs and HTTP headers, we Base64Url Encode these bytes into text characters (A-Z, 0-9, -, _).
This ensures that even though anyone can read the token, no one can generate a valid signature for a fake payload without your server’s secret key.
Let’s look at how to implement this using the industry-standard package github.com/golang-jwt/jwt/v5.
First, we define what data we want to store inside the token.
import (
"github.com/golang-jwt/jwt/v5"
"time"
)
// Define a custom struct to hold the token claims
type UserClaims struct {
UserID string `json:"user_id"`
Role string `json:"role"`
jwt.RegisteredClaims // Embed standard claims (exp, iss, etc.)
}
// In production, keep this safe! Never hardcode it in your source code.
var jwtKey = []byte("my_secret_key")When a user logs in successfully, we generate the token. Notice how we “sign” it at the end—this is where the hashing happens.
func GenerateToken(userID, role string) (string, error) {
// 1. Create the Claims
claims := UserClaims{
UserID: userID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), // Short expiry!
Issuer: "my-app",
},
}
// 2. Create the token using the HS256 algorithm
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// 3. Sign the token with our secret key
// This generates the binary hash and Base64 encodes it for you
signedToken, err := token.SignedString(jwtKey)
if err != nil {
return "", err
}
return signedToken, nil
}When the server receives a token, it doesn’t “decrypt” it. Instead, it re-calculates the hash using the jwtKey to see if it matches the signature provided.
func VerifyToken(tokenString string) (*UserClaims, error) {
// Parse the token
token, err := jwt.ParseWithClaims(tokenString, &UserClaims{}, func(token *jwt.Token) (interface{}, error) {
// Validate the alg is what you expect (Crucial security step!)
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return jwtKey, nil
})
if err != nil {
return nil, err
}
// Check if the token is valid (signature match) and not expired
if claims, ok := token.Claims.(*UserClaims); ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}This is the most common use case. Because JWTs are self-contained, they are easily passed between different domains (e.g., auth.example.com and app.example.com), making them perfect for SSO.
Because the tokens are signed, the receiver can be 100% sure that the sender is who they claim to be. If the signature matches, the data is authentic.
JWTs offer a modern, lightweight approach to authentication. The key takeaway is that they guarantee integrity, not secrecy. The signature ensures that the data you receive is exactly what was sent, signed by a trusted party. By understanding this distinction, you can build secure, scalable Go applications without falling into common security traps.