JWT authentication is one of those things that looks simple until you see the CVEs. Algorithm confusion, weak secrets, missing expiry — most JWT vulnerabilities aren't exotic. They come from implementation shortcuts that seemed harmless at the time. This article covers how to do JWT auth correctly in Go Fiber, including token generation, validation, and the refresh flow.
Why JWT Auth Fails in Practice
The spec is fine. The failures happen in implementation:
- Algorithm confusion attacks: if you don't explicitly lock the expected signing algorithm, an attacker can switch from RS256 to HS256 and sign tokens with your public key
- Weak secrets: HS256 with a short or predictable secret is brute-forceable — the JWT spec recommends 256 bits (32 bytes) minimum
- Missing expiry: tokens that live forever turn a leaked credential into a permanent backdoor
- No revocation mechanism: a long-lived token you can't invalidate is a liability
Fixing these requires maybe 20 extra lines of code. Let's go through them.
Project Setup
We need three dependencies:
go get github.com/gofiber/fiber/v2
go get github.com/golang-jwt/jwt/v5
go get github.com/redis/go-redis/v9
Use golang-jwt/jwt — it's the actively maintained fork of dgrijalva/jwt-go, which was archived in 2021. Don't use the original.
Project structure:
.
├── main.go
├── handlers/
│ ├── auth.go
│ └── refresh.go
└── middleware/
└── jwt.go
Generating Tokens
Two hard rules: use a secret of at least 32 characters, and always set an expiry. Both should be enforced at the code level, not left to documentation.
// handlers/auth.go
package handlers
import (
"fmt"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID string `json:"sub"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func GenerateAccessToken(userID, role string) (string, error) {
secret := os.Getenv("JWT_SECRET")
if len(secret) < 32 {
return "", fmt.Errorf("JWT_SECRET must be at least 32 characters, got %d", len(secret))
}
claims := Claims{
UserID: userID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "myapp",
Subject: userID,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
15-minute access tokens are short on purpose. Pair them with refresh tokens (covered next) rather than extending the TTL to avoid dealing with expiry.
Validating Tokens: Where Most Bugs Live
The algorithm confusion attack works like this: your server signs tokens with HS256 using a secret. An attacker discovers your RSA public key (often exposed at /.well-known/jwks.json). They craft a token, set alg: HS256, and sign it with your public key as the HMAC secret. If your parser accepts whichever algorithm the token claims, it will verify this forged token as valid.
The fix is a single type assertion in the key function:
// middleware/jwt.go
package middleware
import (
"fmt"
"os"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
"yourapp/handlers"
)
func JWTProtected() fiber.Handler {
return func(c *fiber.Ctx) error {
authHeader := c.Get("Authorization")
if len(authHeader) < 8 || authHeader[:7] != "Bearer " {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "missing or malformed authorization header",
})
}
tokenStr := authHeader[7:]
secret := []byte(os.Getenv("JWT_SECRET"))
token, err := jwt.ParseWithClaims(
tokenStr,
&handlers.Claims{},
func(t *jwt.Token) (interface{}, error) {
// This check prevents algorithm confusion attacks
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return secret, nil
},
)
if err != nil || !token.Valid {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "invalid or expired token",
})
}
claims, ok := token.Claims.(*handlers.Claims)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid claims"})
}
c.Locals("userID", claims.UserID)
c.Locals("role", claims.Role)
return c.Next()
}
}
The t.Method.(*jwt.SigningMethodHMAC) assertion is the guard. If the token arrives claiming alg: RS256 (or none), the assertion fails, the key function returns an error, and the token is rejected before any further processing.
Wiring It Into Fiber
// main.go
package main
import (
"log"
"github.com/gofiber/fiber/v2"
"github.com/joho/godotenv"
"yourapp/handlers"
"yourapp/middleware"
)
func main() {
if err := godotenv.Load(); err != nil {
log.Fatal("could not load .env file")
}
app := fiber.New()
// Public routes
app.Post("/auth/login", handlers.Login)
app.Post("/auth/refresh", handlers.RefreshToken)
// Protected routes — middleware applied at group level
api := app.Group("/api", middleware.JWTProtected())
api.Get("/profile", handlers.GetProfile)
api.Post("/settings", handlers.UpdateSettings)
log.Fatal(app.Listen(":3000"))
}
Applying the middleware at the group level means new routes added under /api are protected by default — no risk of accidentally exposing a route by forgetting to add a middleware decorator.
Refresh Tokens: Making Short Expiry Practical
Short access tokens only work if refresh is seamless. Store refresh tokens in Redis with a 7-day TTL. On each use, rotate them — issue a new refresh token and delete the old one. This limits the exposure window for a stolen token.
// handlers/refresh.go
package handlers
import (
"context"
"crypto/rand"
"encoding/hex"
"time"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
)
var RDB *redis.Client
func generateSecureToken() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
func RefreshToken(c *fiber.Ctx) error {
body := struct {
RefreshToken string `json:"refresh_token"`
}{}
if err := c.BodyParser(&body); err != nil || body.RefreshToken == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
}
ctx := context.Background()
key := "refresh:" + body.RefreshToken
userID, err := RDB.Get(ctx, key).Result()
if err == redis.Nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "refresh token invalid or expired"})
}
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "internal error"})
}
// Rotate: delete old token and issue new one atomically
pipe := RDB.Pipeline()
pipe.Del(ctx, key)
newRefreshToken := generateSecureToken()
pipe.Set(ctx, "refresh:"+newRefreshToken, userID, 7*24*time.Hour)
if _, err := pipe.Exec(ctx); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "token rotation failed"})
}
accessToken, err := GenerateAccessToken(userID, "user")
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "token generation failed"})
}
return c.JSON(fiber.Map{
"access_token": accessToken,
"refresh_token": newRefreshToken,
})
}
Using a Redis pipeline for the rotation makes both operations (delete old, insert new) atomic from the client's perspective, which avoids race conditions on concurrent requests arriving with the same token.
Note that crypto/rand is used for generating the refresh token — never math/rand. The difference is cryptographic unpredictability.
The Takeaway
JWT is a solved problem when you implement it without shortcuts:
- Lock the signing algorithm in the key function — the type assertion is non-negotiable and stops algorithm confusion cold
- Keep access tokens short (15 minutes) and pair them with rotating, server-stored refresh tokens
- Enforce secret length at startup — fail fast with a clear error rather than silently accepting a weak secret
-
Use
golang-jwt/jwt/v5, not the archived original package
These aren't edge cases. Algorithm confusion has a CVE (CVE-2015-9235), weak secrets get cracked in seconds with offline attacks, and tokens without expiry are a staple of incident post-mortems.
For teams doing a broader API security review, the free security hardening checklists cover JWT configuration alongside session management, CORS, and input validation — available as PDF and Excel.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)