Tokens and JWT
A JWT is a signed statement a server can check without a lookup, which is also why it cannot be taken back.
s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)A session id means nothing until the server looks it up. A token carries its own facts instead. A JSON Web Token, or JWT, is one line of text in three parts joined by dots: a header naming the algorithm, a payload of claims, and a signature. Each part is written in base64url, an alphabet of letters, digits, hyphen and underscore that survives being put in a URL or a header.
The signature is the whole point. With HS256 the server runs HMAC-SHA256 over the first two parts using a secret key and appends the result. Anyone can read the payload. Only a holder of the key can produce a matching signature, so an edited payload stops verifying. Check the signature before you believe a single field inside the token.
Claims and expiry
Claims are plain JSON fields. The registered names are short: sub is the subject, usually a user id; exp is the moment the token dies, counted in seconds since 1970-01-01 UTC; iat is when it was issued. Always set exp, and keep it short, in minutes rather than days.
What a token must not carry
A JWT is signed, not encrypted. Anyone holding it can read it, so no passwords and nothing private. Nothing large either, because it travels on every request. And nothing you need to withdraw instantly: a signed token stays valid until exp whatever you do, which is why logout needs either a short expiry or a list of revoked ids that you do look up.
The standard library has no JWT package. github.com/golang-jwt/jwt/v5 is the usual choice. Its key callback is func(*jwt.Token) (any, error); any and interface{} are two spellings of the same type, the same since Go 1.18, so older code writes the second.
Example
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"strings"
)
// secret signs and checks every token. Real code reads it from the environment.
var secret = []byte("0f8c9b2d4e6a8c0f1a3b5d7f9e1c3a5b")
var b64 = base64.RawURLEncoding // base64url, no "=" padding
// mac is the signature over the first two parts, as text.
func mac(signed string) string {
h := hmac.New(sha256.New, secret)
h.Write([]byte(signed)) // writing to a hash never fails
return b64.EncodeToString(h.Sum(nil))
}
func sign(payload string) string {
head := b64.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
signed := head + "." + b64.EncodeToString([]byte(payload))
return signed + "." + mac(signed)
}
// verify recomputes the signature. hmac.Equal compares without leaking timing.
func verify(token string) bool {
i := strings.LastIndex(token, ".")
if i < 0 {
return false
}
return hmac.Equal([]byte(mac(token[:i])), []byte(token[i+1:]))
}
func main() {
token := sign(`{"sub":"u_42","exp":1800000000}`)
fmt.Println(token)
fmt.Println("as issued:", verify(token))
// An attacker swaps the middle part for a different user, keeping the signature.
parts := strings.Split(token, ".")
forged := parts[0] + "." + b64.EncodeToString([]byte(`{"sub":"u_99","exp":1800000000}`)) + "." + parts[2]
fmt.Println("forged:", verify(forged))
}
// Output:
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1XzQyIiwiZXhwIjoxODAwMDAwMDAwfQ.74JWMRVPQIC-_8q9VB029X0Rx9RdM2Bha-ctB3uq5sM
// as issued: true
// forged: false
When to use it
- Let a mobile client prove who it is on every request without a database lookup.
- Pass a verified caller identity from one of your services to another.
- Give a client a short-lived token a second service can check with the same key.
- Reject an expired token with 401 so the client knows to ask for a new one.
More examples
Signing and parsing with golang-jwt/jwt/v5
What you would actually write, after go get github.com/golang-jwt/jwt/v5. Embedding jwt.RegisteredClaims gives your type the methods the library needs, so you can add fields like Role beside the standard ones. The key callback exists because a real service may hold several keys and pick one by the key id in the token header. WithTimeFunc is here only to make the example print the same thing every time; production code leaves it out and the library uses the real clock.
package main
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
// appClaims is the registered claims plus one field of our own.
type appClaims struct {
jwt.RegisteredClaims
Role string `json:"role"`
}
var secret = []byte("0f8c9b2d4e6a8c0f1a3b5d7f9e1c3a5b")
func main() {
// A fixed issue time keeps the output the same on every run.
issued := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
claims := appClaims{
Role: "editor",
RegisteredClaims: jwt.RegisteredClaims{
Subject: "u_42",
IssuedAt: jwt.NewNumericDate(issued),
ExpiresAt: jwt.NewNumericDate(issued.Add(15 * time.Minute)),
},
}
s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
if err != nil {
fmt.Println("sign:", err)
return
}
out := &appClaims{}
_, err = jwt.ParseWithClaims(s, out,
func(*jwt.Token) (any, error) { return secret, nil },
jwt.WithValidMethods([]string{"HS256"}),
jwt.WithTimeFunc(func() time.Time { return issued.Add(time.Minute) }))
if err != nil {
fmt.Println("parse:", err)
return
}
fmt.Println(out.Subject, out.Role, out.ExpiresAt.UTC().Format(time.RFC3339))
}
// Output:
// u_42 editor 2026-01-01T00:15:00Z
An expired token is rejected, and you can say so
Telling an expired token apart from a bad signature matters, because the client should respond differently: an expired token means fetch a new one, a bad signature means something is wrong. errors.Is against jwt.ErrTokenExpired is how you separate them. WithExpirationRequired is worth adding, because without it a token that simply omits exp parses happily and lives forever.
package main
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
var secret = []byte("0f8c9b2d4e6a8c0f1a3b5d7f9e1c3a5b")
func main() {
issued := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
claims := jwt.RegisteredClaims{
Subject: "u_42",
IssuedAt: jwt.NewNumericDate(issued),
ExpiresAt: jwt.NewNumericDate(issued.Add(15 * time.Minute)),
}
s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
if err != nil {
fmt.Println("sign:", err)
return
}
// Parse as though the clock read one hour after the token was issued.
later := func() time.Time { return issued.Add(time.Hour) }
_, err = jwt.ParseWithClaims(s, &jwt.RegisteredClaims{},
func(*jwt.Token) (any, error) { return secret, nil },
jwt.WithValidMethods([]string{"HS256"}),
jwt.WithExpirationRequired(), // refuse a token with no exp at all
jwt.WithTimeFunc(later))
fmt.Println("expired:", errors.Is(err, jwt.ErrTokenExpired))
}
// Output:
// expired: true
Anyone holding a token can read it
Twenty lines and a copy of someone's token is all it takes to read every claim in it. Keep this in mind when deciding what to put in a payload: a plan name or a role is fine, an internal note about the customer is not. Decoding like this is also handy for debugging, but never use it to make a decision, because nothing here checks the signature.
package main
import (
"encoding/base64"
"fmt"
"strings"
)
// payload decodes the middle part. No key is involved, because none is needed.
func payload(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", fmt.Errorf("want 3 parts, got %d", len(parts))
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", err
}
return string(raw), nil
}
func main() {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
"eyJzdWIiOiJ1XzQyIiwiZXhwIjoxODAwMDAwMDAwfQ." +
"74JWMRVPQIC-_8q9VB029X0Rx9RdM2Bha-ctB3uq5sM"
body, err := payload(token)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(body)
}
// Output:
// {"sub":"u_42","exp":1800000000}
Discussion