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:
- Set up Fiber — App configuration, routing, and middleware
- Create and test endpoints — CRUD APIs with automated testing
- Handle files and params — Downloads, route parameters, JSON APIs
- Manage cookies — Setting, reading, and clearing cookies securely
- Use params and queries — Type-safe extraction, filtering, pagination
- Handle forms and middleware flow — File uploads, Locals, Next()
- Craft responses — Send, SendStatus, SendFile, JSON, redirects
- 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! 🚀