In the previous tutorial, you learned how to test Go code. Now it is time to add authentication to your API. Middleware is the standard way to handle cross-cutting concerns like authentication, logging, and CORS.

What is Middleware?

Middleware is a function that runs before (or after) your handler. It sits between the request and the handler:

Request → Middleware 1 → Middleware 2 → Handler → Response

Common uses for middleware:

  • Logging — log every request
  • Authentication — check if the user is logged in
  • CORS — handle cross-origin requests
  • Rate limiting — limit requests per second
  • Request ID — add a unique ID to every request

Gin Middleware Basics

Gin comes with two built-in middlewares. When you use gin.Default(), you get:

  • Logger — logs every request with method, path, status, and time
  • Recovery — catches panics and returns a 500 error
// gin.Default() = gin.New() + Logger + Recovery
r := gin.Default()

// Or use gin.New() and add middleware manually
r := gin.New()
r.Use(gin.Logger())
r.Use(gin.Recovery())

Writing Custom Middleware

A Gin middleware is a function that takes *gin.Context and calls c.Next() to continue to the next handler:

package main

import (
    "fmt"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
)

// RequestLogger logs every request with timing
func RequestLogger() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        path := c.Request.URL.Path
        method := c.Request.Method

        // Before handler
        c.Next()

        // After handler
        elapsed := time.Since(start)
        status := c.Writer.Status()

        fmt.Printf("[%s] %s %s %d %v\n", time.Now().Format("15:04:05"), method, path, status, elapsed)
    }
}

// RequestID adds a unique ID to every request
func RequestID() gin.HandlerFunc {
    return func(c *gin.Context) {
        id := fmt.Sprintf("%d", time.Now().UnixNano())
        c.Set("request_id", id)
        c.Header("X-Request-ID", id)
        c.Next()
    }
}

func main() {
    r := gin.New()
    r.Use(gin.Recovery())
    r.Use(RequestLogger())
    r.Use(RequestID())

    r.GET("/", func(c *gin.Context) {
        requestID, _ := c.Get("request_id")
        c.JSON(http.StatusOK, gin.H{
            "message":    "Hello!",
            "request_id": requestID,
        })
    })

    r.Run(":8080")
}

Key points about middleware:

  • c.Next() — calls the next middleware or handler
  • c.Abort() — stops the chain (no more handlers run)
  • c.Set(key, value) — store data for later handlers
  • c.Get(key) — retrieve data from earlier middleware

CORS Middleware

Cross-Origin Resource Sharing (CORS) lets browsers make requests to your API from different domains:

func CORSMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Header("Access-Control-Allow-Origin", "*")
        c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
        c.Header("Access-Control-Max-Age", "86400")

        if c.Request.Method == "OPTIONS" {
            c.AbortWithStatus(http.StatusNoContent)
            return
        }

        c.Next()
    }
}

func main() {
    r := gin.Default()
    r.Use(CORSMiddleware())

    // Your routes here
    r.Run(":8080")
}

For production, replace * with your actual frontend domain.

Password Hashing with bcrypt

Before we build JWT authentication, we need to hash passwords. Never store plain text passwords. Use bcrypt:

go get golang.org/x/crypto/bcrypt
package main

import (
    "fmt"

    "golang.org/x/crypto/bcrypt"
)

func HashPassword(password string) (string, error) {
    bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    return string(bytes), err
}

func CheckPassword(password, hash string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
    return err == nil
}

func main() {
    password := "mysecretpassword"

    hash, err := HashPassword(password)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }

    fmt.Println("Hash:", hash)
    fmt.Println("Match:", CheckPassword(password, hash))       // true
    fmt.Println("Wrong:", CheckPassword("wrongpassword", hash)) // false
}

bcrypt.DefaultCost is 10. Higher cost means slower hashing — better security but more CPU. The default is fine for most applications.

JWT Authentication

JSON Web Tokens (JWT) are the standard way to authenticate API requests. The flow is:

  1. User sends username and password to /login
  2. Server verifies credentials and returns a JWT token
  3. User sends the token in the Authorization header for future requests
  4. Server validates the token in middleware

Install the JWT library:

go get github.com/golang-jwt/jwt/v5

Creating Tokens

package main

import (
    "time"

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

var jwtSecret = []byte("your-secret-key-change-in-production")

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

func GenerateToken(userID, email string) (string, error) {
    claims := Claims{
        UserID: userID,
        Email:  email,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            Issuer:    "go-tutorial",
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(jwtSecret)
}

func ValidateToken(tokenString string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
        return jwtSecret, nil
    })

    if err != nil {
        return nil, err
    }

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

    return claims, nil
}

Auth Middleware

Now build the middleware that validates tokens on protected routes:

func AuthMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        authHeader := c.GetHeader("Authorization")

        if authHeader == "" {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header required"})
            c.Abort()
            return
        }

        // Token format: "Bearer <token>"
        tokenString := strings.TrimPrefix(authHeader, "Bearer ")

        if tokenString == authHeader {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "bearer token required"})
            c.Abort()
            return
        }

        claims, err := ValidateToken(tokenString)
        if err != nil {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
            c.Abort()
            return
        }

        // Store user info in context for handlers
        c.Set("user_id", claims.UserID)
        c.Set("email", claims.Email)
        c.Next()
    }
}

Complete Authentication Example

Here is everything together — registration, login, and protected routes:

package main

import (
    "fmt"
    "net/http"
    "strings"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/golang-jwt/jwt/v5"
    "golang.org/x/crypto/bcrypt"
)

var jwtSecret = []byte("your-secret-key-change-in-production")

type User struct {
    ID       string `json:"id"`
    Email    string `json:"email"`
    Password string `json:"-"` // Never include in JSON responses
    Name     string `json:"name"`
}

type LoginRequest struct {
    Email    string `json:"email" binding:"required,email"`
    Password string `json:"password" binding:"required,min=6"`
}

type RegisterRequest struct {
    Email    string `json:"email" binding:"required,email"`
    Password string `json:"password" binding:"required,min=6"`
    Name     string `json:"name" binding:"required"`
}

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

// In-memory store (use a database in production)
var users = map[string]*User{}
var nextID = 1

func main() {
    r := gin.Default()

    // Public routes
    public := r.Group("/api")
    {
        public.POST("/register", register)
        public.POST("/login", login)
    }

    // Protected routes
    protected := r.Group("/api")
    protected.Use(AuthMiddleware())
    {
        protected.GET("/profile", getProfile)
        protected.GET("/users", listUsers)
    }

    r.Run(":8080")
}

func register(c *gin.Context) {
    var req RegisterRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    // Check if email already exists
    for _, u := range users {
        if u.Email == req.Email {
            c.JSON(http.StatusConflict, gin.H{"error": "email already registered"})
            return
        }
    }

    // Hash password
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
        return
    }

    user := &User{
        ID:       fmt.Sprintf("%d", nextID),
        Email:    req.Email,
        Password: string(hashedPassword),
        Name:     req.Name,
    }
    nextID++
    users[user.ID] = user

    c.JSON(http.StatusCreated, gin.H{
        "id":    user.ID,
        "email": user.Email,
        "name":  user.Name,
    })
}

func login(c *gin.Context) {
    var req LoginRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    // Find user by email
    var foundUser *User
    for _, u := range users {
        if u.Email == req.Email {
            foundUser = u
            break
        }
    }

    if foundUser == nil {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid email or password"})
        return
    }

    // Check password
    if err := bcrypt.CompareHashAndPassword([]byte(foundUser.Password), []byte(req.Password)); err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid email or password"})
        return
    }

    // Generate JWT token
    token, err := generateToken(foundUser.ID, foundUser.Email)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
        return
    }

    c.JSON(http.StatusOK, gin.H{"token": token})
}

func getProfile(c *gin.Context) {
    userID, _ := c.Get("user_id")

    user, ok := users[userID.(string)]
    if !ok {
        c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "id":    user.ID,
        "email": user.Email,
        "name":  user.Name,
    })
}

func listUsers(c *gin.Context) {
    var result []gin.H
    for _, u := range users {
        result = append(result, gin.H{
            "id":    u.ID,
            "email": u.Email,
            "name":  u.Name,
        })
    }
    c.JSON(http.StatusOK, result)
}

func generateToken(userID, email string) (string, error) {
    claims := Claims{
        UserID: userID,
        Email:  email,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            Issuer:    "go-tutorial",
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(jwtSecret)
}

func AuthMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        authHeader := c.GetHeader("Authorization")

        if authHeader == "" {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header required"})
            c.Abort()
            return
        }

        tokenString := strings.TrimPrefix(authHeader, "Bearer ")

        if tokenString == authHeader {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "bearer token required"})
            c.Abort()
            return
        }

        token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
            return jwtSecret, nil
        })

        if err != nil {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
            c.Abort()
            return
        }

        claims, ok := token.Claims.(*Claims)
        if !ok || !token.Valid {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
            c.Abort()
            return
        }

        c.Set("user_id", claims.UserID)
        c.Set("email", claims.Email)
        c.Next()
    }
}

Test the complete flow:

# Register
curl -X POST http://localhost:8080/api/register \
  -H "Content-Type: application/json" \
  -d '{"email":"alex@example.com","password":"secret123","name":"Alex"}'

# Login — get token
curl -X POST http://localhost:8080/api/login \
  -H "Content-Type: application/json" \
  -d '{"email":"alex@example.com","password":"secret123"}'

# Access protected route — use the token from login
curl http://localhost:8080/api/profile \
  -H "Authorization: Bearer YOUR_TOKEN_HERE"

Middleware for Specific Routes

You can apply middleware to individual routes or groups:

// Apply to all routes
r.Use(CORSMiddleware())

// Apply to a group
protected := r.Group("/api")
protected.Use(AuthMiddleware())

// Apply to a single route
r.GET("/admin", AuthMiddleware(), AdminMiddleware(), adminHandler)

Middleware runs in the order you add it. For the single route example, AuthMiddleware runs first, then AdminMiddleware, then adminHandler.

Middleware Execution Order

Understanding the order is important:

func MiddlewareA() gin.HandlerFunc {
    return func(c *gin.Context) {
        fmt.Println("A: before")
        c.Next()
        fmt.Println("A: after")
    }
}

func MiddlewareB() gin.HandlerFunc {
    return func(c *gin.Context) {
        fmt.Println("B: before")
        c.Next()
        fmt.Println("B: after")
    }
}

// Output:
// A: before
// B: before
// Handler
// B: after
// A: after

Code before c.Next() runs top-down. Code after c.Next() runs bottom-up. This is useful for timing middleware — start the timer before, calculate elapsed time after.

Common Mistakes

1. Forgetting c.Abort() after error responses.

// Wrong — handler still runs
if !isAuthenticated {
    c.JSON(401, gin.H{"error": "unauthorized"})
    // Missing c.Abort()!
}
c.Next()

Always call c.Abort() when you want to stop the chain.

2. Storing secrets in code.

// Wrong — secret is in your source code
var jwtSecret = []byte("my-secret")

// Better — read from environment variable
var jwtSecret = []byte(os.Getenv("JWT_SECRET"))

Use environment variables for secrets. Never commit them to git.

3. Not checking the token signing method.

Always verify that the token uses the expected signing method. The ParseWithClaims function does this by default when you return the correct key type, but be careful with custom key functions.

Source Code

You can find the complete source code for this tutorial on GitHub:

GO-18 Source Code on GitHub

What’s Next?

In the next tutorial, Go Tutorial #19: Database Access with sqlx, you will learn:

  • Go’s database/sql interface
  • sqlx for easier database access
  • CRUD operations with PostgreSQL
  • Transactions and connection pooling

This is part 18 of the Go Tutorial series. Follow along to learn Go from scratch.