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 handlerc.Abort()— stops the chain (no more handlers run)c.Set(key, value)— store data for later handlersc.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:
- User sends username and password to
/login - Server verifies credentials and returns a JWT token
- User sends the token in the
Authorizationheader for future requests - 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:
Related Articles
- Go Tutorial #16: Building REST APIs with Gin — Build the API to protect
- Go Tutorial #17: Testing in Go — Test your middleware and auth
- Go Tutorial #19: Database Access with sqlx — Store users in a database
What’s Next?
In the next tutorial, Go Tutorial #19: Database Access with sqlx, you will learn:
- Go’s
database/sqlinterface - 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.