15 min read
Implemented in Registration Kit
Full-stack auth starter: registration, login, 2FA, and password reset — React + Express + MongoDB
See Registration KitMost Go JWT tutorials stop at "here is how to sign a token and here is how to parse it". That's about fifteen minutes of the work. The rest — what goes in the claims, how the middleware carries identity, and what happens when a token is stolen — is where the real decisions are, and where the security holes live.
This uses github.com/golang-jwt/jwt/v5, the maintained fork. If you're on dgrijalva/jwt-go, that repository has been unmaintained since 2021 and had a CVE; migrating is a package rename and a few signature changes.
Everyone nods at this and then puts an email address in the payload.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJyb2xlIjoiYWRtaW4ifQ.xxx
└── base64. Anyone can read this. Including the user.The signature guarantees nobody modified the claims. It guarantees nothing about who can read them. So: user id, role, expiry — fine. Email, phone number, anything you would not print in a log — not fine, because the token is in the browser and will end up in a log.
type Claims struct {
jwt.RegisteredClaims
Role string `json:"role"`
TenantID string `json:"tid,omitempty"`
}
func NewAccessToken(secret []byte, userID, role, tenantID string, ttl time.Duration) (string, error) {
now := time.Now()
claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
NotBefore: jwt.NewNumericDate(now),
Issuer: "dashforge",
Audience: jwt.ClaimStrings{"api"},
ID: uuid.NewString(), // jti — needed for revocation
},
Role: role,
TenantID: tenantID,
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}Three things worth defending here.
ExpiresAt short. Fifteen minutes for an access token. This is the entire reason the refresh flow below exists: a stolen access token stops working before the attacker has finished reading your API docs.
Issuer and Audience set. They cost nothing and they stop a token minted by your staging environment, or by a different service sharing the secret, from being accepted here.
ID (the jti). You cannot revoke a token you cannot name. Skip this and "log out everywhere" is not implementable.
Put the role in the token only if it's cheap to be a few minutes stale. Demoting an admin won't take effect until their access token expires. With a 15-minute TTL that's usually fine; if it isn't, look the role up per request and accept the database hit.
func Parse(secret []byte, raw string) (*Claims, error) {
token, err := jwt.ParseWithClaims(raw, &Claims{},
func(t *jwt.Token) (any, error) {
// THE line. Without it, an attacker picks the algorithm.
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return secret, nil
},
jwt.WithIssuer("dashforge"),
jwt.WithAudience("api"),
jwt.WithExpirationRequired(),
)
if err != nil {
return nil, apperr.Forbidden("invalid token")
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, apperr.Forbidden("invalid token")
}
return claims, nil
}Also note jwt.WithExpirationRequired(). Without it, a token with no exp claim at all is considered valid forever.
type ctxKey int
const claimsKey ctxKey = iota
func RequireAuth(secret []byte) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, ok := bearer(r)
if !ok {
httpx.Error(w, apperr.Forbidden("missing bearer token"))
return
}
claims, err := Parse(secret, raw)
if err != nil {
httpx.Error(w, err)
return
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func bearer(r *http.Request) (string, bool) {
h := r.Header.Get("Authorization")
if len(h) < 8 || !strings.EqualFold(h[:7], "bearer ") {
return "", false
}
return h[7:], true
}
// The only way the rest of the app reads identity.
func UserID(ctx context.Context) string {
c, ok := ctx.Value(claimsKey).(*Claims)
if !ok {
return ""
}
return c.Subject
}The unexported ctxKey type matters: a context key of type string can collide with any other package that also used a string, and the compiler will not warn you. An unexported type in your package cannot collide with anything.
Mount it per-route, never globally:
r.Post("/auth/login", authH.Login) // public
r.Get("/health", healthH.Get) // public
r.Group(func(pr chi.Router) {
pr.Use(auth.RequireAuth(cfg.JWTSecret))
pr.Use(auth.RBACGate(policies))
pr.Post("/orders", orderH.Create)
})Applying auth globally and then carving out exceptions is how a public endpoint eventually ends up protected — or a private one open.
Short access tokens are only tolerable if there's a way to get a new one. The refresh token is what makes the whole scheme work, and it needs different properties:
| Access token | Refresh token | |
|---|---|---|
| Lifetime | 15 minutes | 30 days |
| Stored server-side | no | yes, hashed |
| Sent on | every request | only /auth/refresh |
| Held in | memory | httpOnly cookie |
| Revocable | via jti denylist | delete the row |
A refresh token is not a JWT. It's a random string you store hashed, exactly like a password:
func NewRefreshToken(ctx context.Context, store RefreshStore, userID string) (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil { // crypto/rand
return "", err
}
token := base64.RawURLEncoding.EncodeToString(raw)
sum := sha256.Sum256([]byte(token))
err := store.Save(ctx, RefreshRecord{
UserID: userID,
TokenHash: sum[:], // never store the token itself
FamilyID: uuid.NewString(), // see rotation below
ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
})
return token, err
}SHA-256 rather than bcrypt is deliberate here: the token already has 256 bits of entropy from crypto/rand, so there's nothing to brute-force, and refresh happens often enough that bcrypt's cost would be felt.
This is the mechanism that turns a stolen refresh token from a disaster into an inconvenience.
Every refresh issues a new refresh token and invalidates the old one. Tokens descending from one login share a FamilyID. If a token that was already used comes back, one of two people has it — the legitimate user or a thief — and you cannot tell which. So you kill the whole family and make both log in again.
func (s *AuthService) Refresh(ctx context.Context, presented string) (*TokenPair, error) {
sum := sha256.Sum256([]byte(presented))
rec, err := s.store.ByHash(ctx, sum[:])
if err != nil {
return nil, apperr.Forbidden("invalid refresh token")
}
// Already rotated once: someone is replaying. Burn the family.
if rec.UsedAt != nil {
_ = s.store.RevokeFamily(ctx, rec.FamilyID)
s.log.Warn("refresh token reuse", "user", rec.UserID, "family", rec.FamilyID)
return nil, apperr.Forbidden("invalid refresh token")
}
if time.Now().After(rec.ExpiresAt) {
return nil, apperr.Forbidden("refresh token expired")
}
if err := s.store.MarkUsed(ctx, rec.ID); err != nil {
return nil, err
}
access, err := NewAccessToken(s.secret, rec.UserID, rec.Role, rec.TenantID, 15*time.Minute)
if err != nil {
return nil, err
}
next, err := s.store.RotateWithin(ctx, rec.FamilyID, rec.UserID)
if err != nil {
return nil, err
}
return &TokenPair{Access: access, Refresh: next}, nil
}The attacker steals a refresh token and uses it. The real user's next refresh presents the now-used token, reuse is detected, the family dies, and the attacker is out — with a log line telling you it happened. Without rotation, the thief simply refreshes forever and you never find out.
A signed token is valid until it expires. There is no "cancel" — that's the trade you accepted for stateless verification.
So: logout deletes the refresh record. The access token stays valid for up to 15 more minutes, which is why the TTL is short. For a real "kill this session now" — a stolen laptop, a fired employee — keep a small denylist of jti values with the token's own expiry as the TTL, and check it in the middleware. It's a cache lookup per request, and the denylist stays small because entries evict themselves when the token would have expired anyway.
If you find yourself checking a denylist on every request for every user, you have rebuilt sessions with extra steps, and sessions might be the better answer for your app.
iss, aud, exp, and a jti.context under an unexported key type.All of this ships wired in Registration Kit — Go and Node editions, with TOTP two-factor, backup codes and password reset on top of the same token model.