← Back to all tutorials
Go FiberEpisode 8

Build an API with JWT Auth

Build a complete production-ready REST API with JWT authentication in Go Fiber. This capstone tutorial covers user registration with password hashing, login with JWT token generation, protected routes with middleware, refresh tokens, role-based access control, and security best practices.

This is the capstone of the Go Fiber tutorial series. We'll build a complete REST API with JWT authentication — user registration, login, protected routes, refresh tokens, and role-based access control. This is the pattern you'll use in every production Fiber application.

Project Setup

mkdir fiber-jwt-api
cd fiber-jwt-api
go mod init fiber-jwt-api
go get github.com/gofiber/fiber/v2
go get github.com/golang-jwt/jwt/v5
go get golang.org/x/crypto/bcrypt
go get github.com/google/uuid
go get github.com/joho/godotenv

Create .env:

JWT_SECRET=your-super-secret-key-change-this-in-production
JWT_REFRESH_SECRET=another-secret-for-refresh-tokens
PORT=3000

User Model

Create models/user.go:

package models

import "time"

type User struct {
    ID        string    `json:"id"`
    Name      string    `json:"name"`
    Email     string    `json:"email"`
    Password  string    `json:"-"`  // Never include in JSON responses
    Role      string    `json:"role"`
    CreatedAt time.Time `json:"created_at"`
}

type RegisterRequest struct {
    Name     string `json:"name"`
    Email    string `json:"email"`
    Password string `json:"password"`
}

type LoginRequest struct {
    Email    string `json:"email"`
    Password string `json:"password"`
}

type AuthResponse struct {
    AccessToken  string `json:"access_token"`
    RefreshToken string `json:"refresh_token"`
    TokenType    string `json:"token_type"`
    ExpiresIn    int    `json:"expires_in"`
    User         User   `json:"user"`
}

type RefreshRequest struct {
    RefreshToken string `json:"refresh_token"`
}

The json:"-" tag on Password ensures it's never included in JSON responses — a critical security detail.

JWT Token Service

Create services/jwt.go:

package services

import (
    "errors"
    "os"
    "time"

    "github.com/golang-jwt/jwt/v5"
)

type TokenClaims struct {
    UserID string `json:"user_id"`
    Email  string `json:"email"`
    Role   string `json:"role"`
    jwt.RegisteredClaims
}

// GenerateAccessToken creates a short-lived access token (15 minutes)
func GenerateAccessToken(userID, email, role string) (string, error) {
    claims := TokenClaims{
        UserID: userID,
        Email:  email,
        Role:   role,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            Issuer:    "fiber-jwt-api",
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte(os.Getenv("JWT_SECRET")))
}

// GenerateRefreshToken creates a long-lived refresh token (7 days)
func GenerateRefreshToken(userID string) (string, error) {
    claims := jwt.RegisteredClaims{
        Subject:   userID,
        ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
        IssuedAt:  jwt.NewNumericDate(time.Now()),
        Issuer:    "fiber-jwt-api",
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte(os.Getenv("JWT_REFRESH_SECRET")))
}

// ValidateAccessToken validates and parses an access token
func ValidateAccessToken(tokenString string) (*TokenClaims, error) {
    token, err := jwt.ParseWithClaims(tokenString, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
        // Verify signing method
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, errors.New("unexpected signing method")
        }
        return []byte(os.Getenv("JWT_SECRET")), nil
    })

    if err != nil {
        return nil, err
    }

    claims, ok := token.Claims.(*TokenClaims)
    if !ok || !token.Valid {
        return nil, errors.New("invalid token")
    }

    return claims, nil
}

// ValidateRefreshToken validates a refresh token and returns the user ID
func ValidateRefreshToken(tokenString string) (string, error) {
    token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, errors.New("unexpected signing method")
        }
        return []byte(os.Getenv("JWT_REFRESH_SECRET")), nil
    })

    if err != nil {
        return "", err
    }

    claims, ok := token.Claims.(*jwt.RegisteredClaims)
    if !ok || !token.Valid {
        return "", errors.New("invalid refresh token")
    }

    return claims.Subject, nil
}

Auth Middleware

Create middleware/auth.go:

package middleware

import (
    "fiber-jwt-api/services"
    "strings"

    "github.com/gofiber/fiber/v2"
)

// AuthRequired validates the JWT token and sets user context
func AuthRequired(c *fiber.Ctx) error {
    // Get Authorization header
    authHeader := c.Get("Authorization")
    if authHeader == "" {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": "Authorization header is required",
        })
    }

    // Extract token from "Bearer "
    parts := strings.Split(authHeader, " ")
    if len(parts) != 2 || parts[0] != "Bearer" {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": "Invalid authorization format. Use: Bearer ",
        })
    }

    tokenString := parts[1]

    // Validate the token
    claims, err := services.ValidateAccessToken(tokenString)
    if err != nil {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": "Invalid or expired token",
        })
    }

    // Set user info in Locals for downstream handlers
    c.Locals("userID", claims.UserID)
    c.Locals("email", claims.Email)
    c.Locals("role", claims.Role)

    return c.Next()
}

// RequireRole checks if the authenticated user has one of the allowed roles
func RequireRole(roles ...string) fiber.Handler {
    return func(c *fiber.Ctx) error {
        userRole, ok := c.Locals("role").(string)
        if !ok {
            return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
                "error": "Role information not found",
            })
        }

        for _, role := range roles {
            if userRole == role {
                return c.Next()
            }
        }

        return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
            "error": "Insufficient permissions",
        })
    }
}

Auth Handlers

Create handlers/auth.go:

package handlers

import (
    "fiber-jwt-api/models"
    "fiber-jwt-api/services"
    "time"

    "github.com/gofiber/fiber/v2"
    "github.com/google/uuid"
    "golang.org/x/crypto/bcrypt"
)

// In-memory user store (replace with database in production)
var users = make(map[string]models.User)
var usersByEmail = make(map[string]string) // email -> user ID

// Register creates a new user account
func Register(c *fiber.Ctx) error {
    var req models.RegisterRequest

    if err := c.BodyParser(&req); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "Invalid request body",
        })
    }

    // Validate fields
    if req.Name == "" || req.Email == "" || req.Password == "" {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "Name, email, and password are required",
        })
    }

    if len(req.Password) < 8 {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "Password must be at least 8 characters",
        })
    }

    // Check if email already exists
    if _, exists := usersByEmail[req.Email]; exists {
        return c.Status(fiber.StatusConflict).JSON(fiber.Map{
            "error": "Email already registered",
        })
    }

    // Hash the password
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "Failed to process password",
        })
    }

    // Create user
    user := models.User{
        ID:        uuid.New().String(),
        Name:      req.Name,
        Email:     req.Email,
        Password:  string(hashedPassword),
        Role:      "user", // Default role
        CreatedAt: time.Now(),
    }

    users[user.ID] = user
    usersByEmail[user.Email] = user.ID

    // Generate tokens
    accessToken, err := services.GenerateAccessToken(user.ID, user.Email, user.Role)
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "Failed to generate token",
        })
    }

    refreshToken, err := services.GenerateRefreshToken(user.ID)
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "Failed to generate refresh token",
        })
    }

    return c.Status(fiber.StatusCreated).JSON(models.AuthResponse{
        AccessToken:  accessToken,
        RefreshToken: refreshToken,
        TokenType:    "Bearer",
        ExpiresIn:    900, // 15 minutes in seconds
        User:         user,
    })
}

// Login authenticates a user and returns JWT tokens
func Login(c *fiber.Ctx) error {
    var req models.LoginRequest

    if err := c.BodyParser(&req); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "Invalid request body",
        })
    }

    if req.Email == "" || req.Password == "" {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "Email and password are required",
        })
    }

    // Find user by email
    userID, exists := usersByEmail[req.Email]
    if !exists {
        // Don't reveal whether the email exists
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": "Invalid email or password",
        })
    }

    user := users[userID]

    // Verify password
    if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": "Invalid email or password",
        })
    }

    // Generate tokens
    accessToken, err := services.GenerateAccessToken(user.ID, user.Email, user.Role)
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "Failed to generate token",
        })
    }

    refreshToken, err := services.GenerateRefreshToken(user.ID)
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "Failed to generate refresh token",
        })
    }

    return c.JSON(models.AuthResponse{
        AccessToken:  accessToken,
        RefreshToken: refreshToken,
        TokenType:    "Bearer",
        ExpiresIn:    900,
        User:         user,
    })
}

// RefreshTokenHandler exchanges a refresh token for a new access token
func RefreshTokenHandler(c *fiber.Ctx) error {
    var req models.RefreshRequest

    if err := c.BodyParser(&req); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "Invalid request body",
        })
    }

    // Validate refresh token
    userID, err := services.ValidateRefreshToken(req.RefreshToken)
    if err != nil {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": "Invalid or expired refresh token",
        })
    }

    // Get user
    user, exists := users[userID]
    if !exists {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": "User not found",
        })
    }

    // Generate new access token
    accessToken, err := services.GenerateAccessToken(user.ID, user.Email, user.Role)
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "Failed to generate token",
        })
    }

    // Generate new refresh token (token rotation)
    newRefreshToken, err := services.GenerateRefreshToken(user.ID)
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "Failed to generate refresh token",
        })
    }

    return c.JSON(models.AuthResponse{
        AccessToken:  accessToken,
        RefreshToken: newRefreshToken,
        TokenType:    "Bearer",
        ExpiresIn:    900,
        User:         user,
    })
}

// GetProfile returns the authenticated user's profile
func GetProfile(c *fiber.Ctx) error {
    userID := c.Locals("userID").(string)

    user, exists := users[userID]
    if !exists {
        return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
            "error": "User not found",
        })
    }

    return c.JSON(fiber.Map{
        "success": true,
        "data":    user,
    })
}

Protected Resource Handlers

Create handlers/product.go:

package handlers

import "github.com/gofiber/fiber/v2"

// Example protected endpoint
func GetProducts(c *fiber.Ctx) error {
    // User is authenticated — access user info from Locals
    userID := c.Locals("userID").(string)
    role := c.Locals("role").(string)

    products := []fiber.Map{
        {"id": 1, "name": "Go Programming Book", "price": 39.99},
        {"id": 2, "name": "Fiber Framework Guide", "price": 29.99},
    }

    return c.JSON(fiber.Map{
        "success":    true,
        "data":       products,
        "user_id":    userID,
        "user_role":  role,
    })
}

// Admin-only endpoint
func GetAdminDashboard(c *fiber.Ctx) error {
    return c.JSON(fiber.Map{
        "success": true,
        "data": fiber.Map{
            "total_users":    len(users),
            "total_products": 42,
            "revenue":        15280.50,
        },
    })
}

Wiring Everything Together

Create main.go:

package main

import (
    "fiber-jwt-api/handlers"
    "fiber-jwt-api/middleware"
    "log"
    "os"

    "github.com/gofiber/fiber/v2"
    "github.com/gofiber/fiber/v2/middleware/cors"
    "github.com/gofiber/fiber/v2/middleware/logger"
    "github.com/gofiber/fiber/v2/middleware/recover"
    "github.com/joho/godotenv"
)

func main() {
    // Load environment variables
    godotenv.Load()

    app := fiber.New(fiber.Config{
        AppName: "Fiber JWT API v1.0",
        ErrorHandler: func(c *fiber.Ctx, err error) error {
            code := fiber.StatusInternalServerError
            if e, ok := err.(*fiber.Error); ok {
                code = e.Code
            }
            return c.Status(code).JSON(fiber.Map{
                "error": err.Error(),
            })
        },
    })

    // Global middleware
    app.Use(logger.New())
    app.Use(recover.New())
    app.Use(cors.New(cors.Config{
        AllowOrigins: "*",
        AllowMethods: "GET,POST,PUT,DELETE",
        AllowHeaders: "Origin,Content-Type,Accept,Authorization",
    }))

    // Health check
    app.Get("/health", func(c *fiber.Ctx) error {
        return c.JSON(fiber.Map{"status": "healthy"})
    })

    // Auth routes (public)
    auth := app.Group("/auth")
    auth.Post("/register", handlers.Register)
    auth.Post("/login", handlers.Login)
    auth.Post("/refresh", handlers.RefreshTokenHandler)

    // API routes (protected)
    api := app.Group("/api/v1", middleware.AuthRequired)
    api.Get("/profile", handlers.GetProfile)
    api.Get("/products", handlers.GetProducts)

    // Admin routes (protected + admin role required)
    admin := api.Group("/admin", middleware.RequireRole("admin"))
    admin.Get("/dashboard", handlers.GetAdminDashboard)

    // Start server
    port := os.Getenv("PORT")
    if port == "" {
        port = "3000"
    }

    log.Printf("🚀 Server starting on port %s", port)
    log.Fatal(app.Listen(":" + port))
}

Testing the Complete Flow

# 1. Register a new user
curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name":"John Doe","email":"john@example.com","password":"securepass123"}'

# Response includes access_token and refresh_token
# Save the access_token for authenticated requests

# 2. Login
curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"john@example.com","password":"securepass123"}'

# 3. Access protected route
curl http://localhost:3000/api/v1/profile \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

# 4. Access without token (should fail with 401)
curl http://localhost:3000/api/v1/profile
# → {"error": "Authorization header is required"}

# 5. Refresh expired token
curl -X POST http://localhost:3000/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refresh_token":"YOUR_REFRESH_TOKEN"}'

# 6. Access admin route (should fail with 403 for regular users)
curl http://localhost:3000/api/v1/admin/dashboard \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
# → {"error": "Insufficient permissions"}

Security Best Practices

  • Short access token expiry — 15 minutes limits the window if a token is compromised
  • Refresh token rotation — Issue a new refresh token with each refresh request; invalidate the old one
  • Password hashing — Always use bcrypt (or argon2) with proper cost factor
  • Generic error messages — "Invalid email or password" (don't reveal which one is wrong)
  • HTTPS only — Never transmit tokens over HTTP in production
  • Environment variables — Never hardcode JWT secrets in source code
  • Token blacklisting — For logout, maintain a blacklist of revoked tokens (Redis is ideal)
  • Rate limiting — Add rate limiting to login/register endpoints to prevent brute force

Series Summary

Congratulations — you've completed the Go Fiber tutorial series! You now know how to:

  1. Set up Fiber — App configuration, routing, and middleware
  2. Create and test endpoints — CRUD APIs with automated testing
  3. Handle files and params — Downloads, route parameters, JSON APIs
  4. Manage cookies — Setting, reading, and clearing cookies securely
  5. Use params and queries — Type-safe extraction, filtering, pagination
  6. Handle forms and middleware flow — File uploads, Locals, Next()
  7. Craft responses — Send, SendStatus, SendFile, JSON, redirects
  8. Build JWT authentication — Registration, login, protected routes, RBAC

You have everything you need to build production-ready web applications and APIs with Go Fiber. Start building! 🚀

GoFiberTutorialJWTAuthentication