JSON Web Tokens in Go
JSON Web Tokens is a well known and popular open standard that defines a compact way for securely transmitting information between parties as a JSON object.
In this article we will dive into the standard itself to make sure we understand how JWT works. We will also implement a secure Go server that can issue JWTs and verify them. And finally we will review some best practices for securely using JWTs.
The suggested pronunciation of JWT is the same as the English word "jot".
Sponsor
Duplicating microservice environments for testing creates unsustainable costs and operational complexity. Instead, modern engineering teams are adopting application-layer isolation with Signadot’s "sandboxes" - sharing underlying infrastructure while maintaining isolation through smart request routing. This approach cuts infrastructure costs by over 90% while enabling 10x faster testing cycles and boosting developer productivity.
Use Cases
The most common use case for JWT is authorization. Once the user is logged in, each subsequent request will include the JWT, allowing the user to access routes, services, and resources that are permitted with that token. Usually sent in a header, for example “Authorization”.
Note that on this diagram, the Authorization Server and the Resource Server are two separate entities, but technically they could be the same application.
Another use is information exchange. JSON Web Tokens are a good way of securely transmitting information between parties. Because JWTs can be signed, you can be sure the senders are who they say they are.
Format
HEADER.PAYLOAD.SIGNATURE
JWT consists of three concatenated Base64url-encoded strings, separated by dots:
Header: contains metadata about the type of token and the cryptographic algorithms used to secure its contents.
JWS payload (set of claims): contains verifiable security statements, such as the identity of the user and the permissions they are allowed.
JWS signature: used to validate that the token is trustworthy and has not been tampered with. When you use a JWT, you must check its signature before storing and using it.
Payload contains:
Registered claims. The JWT specification defines seven reserved claims that are not required, but are recommended to allow interoperability with third-party applications. Some of them are: iss (issuer), exp (expiration time), sub (subject), aud (audience)
Custom claims, created to share information between parties that agree on using them and are neither registered or public claims.
If you want to play with JWT and put these concepts into practice, you can use jwt.io Debugger to decode, verify, and generate JWTs.
Signature
Signature is very important for security, so we can verify that no one tampered with our token data.
In general, JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA (although Auth0 supports only HMAC and RSA). When tokens are signed using public/private key pairs, the signature also certifies that only the party holding the private key is the one that signed it.
Do not put secret information in the payload or header elements of a JWT unless it is encrypted. Anyone can read claims! Because it' just base64.
Go Server
In Go, the most popular package to work with JWT is golang-jwt/jwt.
go get -u github.com/golang-jwt/jwt/v5
Below is a simple example in a single file of the HTTP server that does the following:
Signing JWT tokens
Verify JWT tokens in middleware
Use RS256 as signing method
Uses labstack/echo router (my favorite one)
Step 1. PEM keys
This server uses RS256 which is more secure than HS256. RSA is assymetric, and public key can be rotated if compromised. But first we need to generate the keys:
openssl genrsa -out private_key.pem 2048
openssl rsa -pubout -in private_key.pem -out public_key.pem
We can then load them in our Go Server:
var (
publicKey *rsa.PublicKey
privateKey *rsa.PrivateKey
)
// Load both public (verify) and private (sign) RSA keys
func init() {
publicKeyData, err := os.ReadFile("./public_key.pem")
if err != nil {
log.Fatal(err)
}
publicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyData)
if err != nil {
log.Fatal(err)
}
privateKeyData, err := os.ReadFile("./private_key.pem")
if err != nil {
log.Fatal(err)
}
privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyData)
if err != nil {
log.Fatal(err)
}
}
Make sure you store these PEM keys securely, for example using Kubernetes Secrets.
Step 2. Issuer
Our server will have an open login endpoint to exchange credentials for a JWT. Here we can go to the database and verify the password (we’ll skip this part in the example).
The party that issues the JWT needs to use the private RSA key.
func main() {
e := echo.New()
e.Use(middleware.Logger())
e.POST("/login", login)
e.Start("127.0.0.1:4242")
}
// We can add custom claims here
type jwtClaims struct {
jwt.RegisteredClaims
}
func login(c echo.Context) error {
username := c.FormValue("username")
password := c.FormValue("password")
// TODO: implement real auth by checking user in the database
if username != "package" || password != "main" {
return echo.ErrUnauthorized
}
// Set expiration time (1h)
claims := &jwtClaims{
jwt.RegisteredClaims{
Subject: username,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
t, err := token.SignedString(privateKey)
if err != nil {
return echo.ErrInternalServerError
}
return c.JSON(http.StatusOK, echo.Map{
"token": t,
})
}
The result of a successful authentication will be a JWT token that expires in 1 hour. Once the client receives the token, there is no way to invalidate it (expire). To minimize misuse of a JWT, the expiry time is usually kept in the order of a few minutes. Typically the client application would refresh the token in the background using the /refresh route for exanple.
Step 3. Middleware
Once the user is logged in, each subsequent request will include the JWT, allowing the user to access the “/api“ route.
The verify uses the public PEM key.
func main() {
e := echo.New()
e.Use(middleware.Logger())
config := echojwt.Config{
NewClaimsFunc: func(c echo.Context) jwt.Claims {
return new(jwtClaims)
},
SigningKey: publicKey,
SigningMethod: jwt.SigningMethodRS256.Name,
}
g := e.Group("/api")
g.Use(echojwt.WithConfig(config))
g.GET("/greet", greet)
e.Start("127.0.0.1:4242")
}
type jwtClaims struct {
jwt.RegisteredClaims
}
func greet(c echo.Context) error {
user := c.Get("user").(*jwt.Token)
claims := user.Claims.(*jwtClaims)
sub := claims.Subject
return c.String(http.StatusOK, fmt.Sprintf("hi %s!", sub))
}
The JOSE framework
It’s also important to mention that apart from JWT, there are other very related standards that are usually called a JOSE framework and include:
JWS: JSON Web Signature, a specification for digitally signing JSON data.
JWE: JSON Web Encryption, a specification for encrypting JSON data.
JWK: JSON Web Key, a specification for representing cryptographic keys in JSON format.
JWT: JSON Web Token, a compact and standardized way of securely transmitting information between parties using JSON data structures.
JWA: JSON Web Algorithms, a specification for defining cryptographic algorithms used in JWE and JWS.
And you can use this useful package lestrrat-go/jwx that has implementation of these JOSE technologies for Go.
Security Best Practices
Keep it safe and simple, do not put secret info into JWT.
Embrace HTTPS. TTPS (SSL/TLS) prevents interception and Man-in-the-Middle attacks, ensuring tokens are transmitted securely.
Use RS256 (asymmetric signing) with public/private key pairs.
Carefully store JWT, for example local storage is not the best place.