Skip to Content
I'm available for work

46 mins read


Building Secure JWT Authentication System with Go using Clean Architecture: Access-Refresh Token, Token Rotations, Caching, Rate Limiting, Docker and more.

Building production-ready authentication system using Go, Gin, PostgreSQL, and Redis. It covers the full auth lifecycle — registration, login, token refresh, and logout — with security hardening applied at every layer.


Security is one of those things developers often thought as secondary thing — and authentication is where that tends to hurt the most. A poorly implemented authentication system can expose user data, allow session hijacking, or leave your API wide open to brute-force attacks. Getting it right from the start matters.

In this article, we’ll build a production-grade JWT authentication system in Go from scratch. We’ll implement a dual-token strategy using short-lived access tokens and long-lived refresh tokens, add token rotation to prevent replay attacks, store token state in Redis for fast revocation, and layer on rate limiting to protect login endpoints from abuse. By the end, you’ll have a complete, secure auth flow that you can drop into any Go backend.

If you’re not yet familiar with some of the tools we’ll be using — JWT (JSON Web Tokens) is an open standard for securely transmitting claims between parties as a signed token. Redis is an in-memory key-value store we’ll use for token storage and rate limiting counters. Gin is a lightweight and fast HTTP web framework for Go that makes routing and middleware straightforward to work with.

The full source code for this project is available at github.com/yehezkiel1086/go-jwt-auth.

Prerequisites

  • Familiarity with Go and basic REST API concepts
  • A working understanding of how HTTP authentication works
  • A Redis and Postgres instance running locally or via Docker
  • Go 1.21+ installed

Getting Started

Project Structure

Before we write any code, let’s establish the project structure. We’ll follow a clean architecture pattern, separating concerns into layers:

|-- README.md
|-- Taskfile.yml
|-- check_rate_limit.sh
|-- cmd
|   `-- http
|       `-- main.go
|-- docker-compose.yml
|-- go.mod
|-- go.sum
`-- internal
    |-- adapter
    |   |-- config
    |   |   `-- config.go
    |   |-- handler
    |   |   |-- auth.go
    |   |   |-- middleware.go
    |   |   |-- router.go
    |   |   `-- user.go
    |   `-- storage
    |       |-- postgres
    |       |   |-- db.go
    |       |   `-- repository
    |       |       `-- user.go
    |       `-- redis
    |           `-- redis.go
    `-- core
        |-- domain
        |   |-- jwt.go
        |   `-- user.go
        |-- port
        |   |-- auth.go
        |   `-- user.go
        |-- service
        |   |-- auth.go
        |   `-- user.go
        `-- util
            |-- jwt.go
            |-- password.go
            `-- rate_limit.go

Start by initialising the Go module:

go mod init github.com/yehezkiel1086/go-jwt-auth

Install Dependencies

go get github.com/gin-gonic/gin
go get github.com/golang-jwt/jwt/v5
go get github.com/joho/godotenv
go get github.com/redis/go-redis/v9
go get golang.org/x/crypto
go get gorm.io/gorm
go get gorm.io/driver/postgres

Hot Reload with Air

Air gives us live reload during development — every time you save a file, it recompiles and restarts the server automatically. Initialise it with:

air init

This generates a .air.toml in your project root. The most important field to update is cmd under [build], which tells Air where your entrypoint lives:

[build]
  cmd = "go build -o ./tmp/main.exe ./cmd/http/main.go"
  bin = "tmp\\main.exe"
  include_ext = ["go", "tpl", "tmpl", "html"]
  exclude_dir = ["assets", "tmp", "vendor", "testdata"]

Task Runner

Instead of memorising long Docker and CLI commands, we’ll use Taskfile as a simple task runner. Initialise it with:

task --init

Then replace the generated Taskfile.yml with the following:

# https://taskfile.dev
version: '3'
dotenv:
  - .env
tasks:
  compose:up:
    desc: "run docker compose services"
    cmd: docker compose up -d

  compose:down:
    desc: "stop docker compose services"
    cmd: docker compose down

  db:cli:
    desc: "access db docker compose cli"
    cmd: docker exec -it postgres psql -U {{ .DB_USER }} -d {{ .DB_NAME }}

  redis:cli:
    desc: "access redis docker compose cli"
    cmd: docker exec -it redis redis-cli -a {{ .REDIS_PASSWORD }}
  dev:
    desc: "run backend api in dev mode"
    cmd: air

Now you can run task dev instead of air, task compose:up to spin up your databases, and task db:cli to drop into a Postgres shell — all without remembering the full commands.

Docker Compose

We need two services: PostgreSQL for persistent storage and Redis for token state and rate limiting. Create docker-compose.yml at the project root:

services:
  db:
    image: postgres:17-alpine
    restart: always
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_USER: ${DB_USER}
      POSTGRES_DB: ${DB_NAME}
    volumes:
      - ./postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    container_name: postgres
  cache:
    image: redis:7.4-alpine
    restart: always
    ports:
      - "6379:6379"
    command: redis-server --save 20 1 --loglevel warning --requirepass ${REDIS_PASSWORD}
    volumes:
      - ./redis_data:/data
    container_name: redis
volumes:
  postgres_data:
  redis_data:

Both services pull from their credentials in .env, which keeps sensitive values out of source control. Spin them up with:

task compose:up

Environment Variables

Create a .env file at the project root. This holds all runtime configuration — database credentials, Redis connection details, and the JWT secrets used to sign tokens:

APP_NAME=go-jwt-auth
APP_ENV=development
HTTP_HOST=127.0.0.1
HTTP_PORT=8080
CORS_ALLOWED_ORIGINS=http://127.0.0.1:3000,http://localhost:3000,https://yourdomain.com
DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME=employees_listings
DB_USER=postgres
DB_PASSWORD=admin
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=admin
REDIS_DB=0
ACCESS_TOKEN_SECRET=8ed5a68fb806c68617a5a52263e68...
REFRESH_TOKEN_SECRET=7749d5d9f686f3b74f9e248c2ee1...
# note: access token duration in mins, refresh token duration in days
ACCESS_TOKEN_DURATION=15
REFRESH_TOKEN_DURATION=7

Never commit your **_.env_** to version control. Add it to _.gitignore_. For the JWT secrets, generate your own strong random values — you can use _openssl rand -hex 64_ to produce them.

Notice we use two separate secrets for access and refresh tokens. This is intentional: if one secret were ever compromised, it wouldn’t automatically invalidate the other token type.

Config

Rather than reading os.Getenv scattered throughout the codebase, we centralise all configuration into a single Container struct that gets loaded once at startup and injected wherever it's needed:

Here’s the complete adapter/config/config.go:

package config
import (
 "os"
 "strings"
 "github.com/joho/godotenv"
)
type (
 Container struct {
  App   *App
  HTTP  *HTTP
  DB    *DB
  Redis *Redis
  JWT   *JWT
  CORS  *CORS
 }
 App struct {
  Name string
  Env  string
  Host string
 }
 HTTP struct {
  Host string
  Port string
 }
 DB struct {
  Host     string
  Port     string
  User     string
  Password string
  Name     string
 }
 Redis struct {
  Host     string
  Port     string
  Password string
  DB       string
 }
 JWT struct {
  Host                 string
  AccessTokenSecret    string
  RefreshTokenSecret   string
  AccessTokenDuration  string
  RefreshTokenDuration string
 }
 CORS struct {
  AllowedOrigins []string
 }
)
func New() (*Container, error) {
 if os.Getenv("APP_ENV") != "production" {
  if err := godotenv.Load(); err != nil {
   return nil, err
  }
 }
 origins := strings.Split(os.Getenv("CORS_ALLOWED_ORIGINS"), ",")
 if len(origins) == 0 || origins[0] == "" {
  origins = []string{"http://localhost:3000"}
 }
 return &Container{
  App: &App{
   Name: os.Getenv("APP_NAME"),
   Env:  os.Getenv("APP_ENV"),
   Host: os.Getenv("HTTP_HOST"),
  },
  HTTP: &HTTP{
   Host: os.Getenv("HTTP_HOST"),
   Port: os.Getenv("HTTP_PORT"),
  },
  DB: &DB{
   Host:     os.Getenv("DB_HOST"),
   Port:     os.Getenv("DB_PORT"),
   User:     os.Getenv("DB_USER"),
   Password: os.Getenv("DB_PASSWORD"),
   Name:     os.Getenv("DB_NAME"),
  },
  Redis: &Redis{
   Host:     os.Getenv("REDIS_HOST"),
   Port:     os.Getenv("REDIS_PORT"),
   Password: os.Getenv("REDIS_PASSWORD"),
   DB:       os.Getenv("REDIS_DB"),
  },
  JWT: &JWT{
   Host:                 os.Getenv("HTTP_HOST"),
   AccessTokenSecret:    os.Getenv("ACCESS_TOKEN_SECRET"),
   RefreshTokenSecret:   os.Getenv("REFRESH_TOKEN_SECRET"),
   AccessTokenDuration:  os.Getenv("ACCESS_TOKEN_DURATION"),
   RefreshTokenDuration: os.Getenv("REFRESH_TOKEN_DURATION"),
  },
  CORS: &CORS{
   AllowedOrigins: origins,
  },
 }, nil
}

In production, environment variables are expected to be injected by the runtime (e.g. a container orchestrator), so godotenv.Load() is intentionally skipped.

User APIs

The user layer is where we define who exists in the system, what data they carry, and what operations can be performed on them. We’ll walk through each layer from the bottom up: domain → repository → port → service → handler.

Domain

The User struct is our core entity. It embeds gorm.Model which gives us ID, CreatedAt, UpdatedAt, and DeletedAt (soft delete) for free. We also define a Role type as a uint32 to keep role checks type-safe — no magic strings or raw integers scattered around the codebase:

The complete core/domain/user.go:

package domain
import "gorm.io/gorm"
type Role uint32
const (
 UserRole Role = 2001
 AdminRole Role = 5150
)
type User struct {
 gorm.Model
 Name string `json:"name" gorm:"size:255;not null;unique"`
 Email string `json:"email" gorm:"size:255;not null;unique"`
 Password string `json:"password" gorm:"size:255;not null"`
 Role Role `json:"role" gorm:"not null;default:2001"`
}

The role values are intentionally non-sequential — a common pattern to make privilege escalation harder to brute-force if a role check ever leaks.

Repository

The repository is the only layer that talks directly to the database. It knows about GORM, about SQL, and nothing else. All methods accept a context.Context so timeouts and cancellations propagate correctly down to the query level:

The complete adapter/storage/postgres/repository/user.go:

package repository
import (
 "context"
 "github.com/yehezkiel1086/go-jwt-auth/internal/adapter/storage/postgres"
 "github.com/yehezkiel1086/go-jwt-auth/internal/core/domain"
)
type UserRepository struct {
 db *postgres.DB
}
func NewUserRepository(db *postgres.DB) *UserRepository {
 return &UserRepository{
  db: db,
 }
}
func (r *UserRepository) CreateUser(ctx context.Context, user *domain.User) error {
 db := r.db.GetDB()
 if err := db.WithContext(ctx).Create(user).Error; err != nil {
  return err
 }
 return nil
}
func (r *UserRepository) GetUsers(ctx context.Context) ([]domain.User, error) {
 db := r.db.GetDB()
 var users []domain.User
 if err := db.WithContext(ctx).Find(&users).Error; err != nil {
  return nil, err
 }
 return users, nil
}
func (r *UserRepository) GetUserByID(ctx context.Context, id uint) (*domain.User, error) {
 db := r.db.GetDB()
 var user domain.User
 if err := db.WithContext(ctx).First(&user, id).Error; err != nil {
  return nil, err
 }
 return &user, nil
}
func (r *UserRepository) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
 db := r.db.GetDB()
 var user domain.User
 if err := db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
  return nil, err
 }
 return &user, nil
}
func (r *UserRepository) UpdateUserByID(ctx context.Context, user *domain.User) error {
 db := r.db.GetDB()
 if err := db.WithContext(ctx).Save(user).Error; err != nil {
  return err
 }
 return nil
}
func (r *UserRepository) DeleteUserByID(ctx context.Context, id uint) error {
 db := r.db.GetDB()
 if err := db.WithContext(ctx).Delete(&domain.User{}, id).Error; err != nil {
  return err
 }
 return nil
}

Save in GORM performs a full upsert — if the record has a primary key, it updates; otherwise it inserts. We use it here after fetching and mutating the user in the service layer, so we're always updating an existing record.

Ports

Ports are Go interfaces that define the contract between layers. The repository port tells the service what database operations are available; the service port tells the handler what business operations are available. Neither side needs to know about the other’s implementation:

The complete core/port/user.go:

package port
import (
 "context"
 "github.com/yehezkiel1086/go-jwt-auth/internal/core/domain"
)
type UserRepository interface {
 CreateUser(ctx context.Context, user *domain.User) error
 GetUserByID(ctx context.Context, id uint) (*domain.User, error)
 GetUserByEmail(ctx context.Context, email string) (*domain.User, error)
 GetUsers(ctx context.Context) ([]domain.User, error)
 UpdateUserByID(ctx context.Context, user *domain.User) error
 DeleteUserByID(ctx context.Context, id uint) error
}
type UserService interface {
 CreateUser(ctx context.Context, user *domain.User) error
 GetUserByID(ctx context.Context, id uint) (*domain.User, error)
 GetUserByEmail(ctx context.Context, email string) (*domain.User, error)
 GetUsers(ctx context.Context) ([]domain.User, error)
 UpdateUserByID(ctx context.Context, id uint, user *domain.User) error
 DeleteUserByID(ctx context.Context, id uint) error
}

Notice the service interface has an extra id uint parameter on UpdateUserByID that the repository doesn't — because the service is responsible for first fetching the user by ID, applying partial updates, then passing the full mutated struct down to the repository.

Service

The service layer holds the business logic. It’s the only place where passwords get hashed, and the only place where partial update logic lives — deciding which fields to apply based on what the caller actually sent:

core/service/user.go:

package service
import (
 "context"
 "github.com/yehezkiel1086/go-jwt-auth/internal/core/domain"
 "github.com/yehezkiel1086/go-jwt-auth/internal/core/port"
 "github.com/yehezkiel1086/go-jwt-auth/internal/core/util"
)
type UserService struct {
 repo port.UserRepository
}
func NewUserService(repo port.UserRepository) *UserService {
 return &UserService{repo: repo}
}
func (s *UserService) CreateUser(ctx context.Context, user *domain.User) error {
 hashed, err := util.HashPassword(user.Password)
 if err != nil {
  return err
 }
 user.Password = hashed
 return s.repo.CreateUser(ctx, user)
}
func (s *UserService) GetUsers(ctx context.Context) ([]domain.User, error) {
 return s.repo.GetUsers(ctx)
}
func (s *UserService) GetUserByID(ctx context.Context, id uint) (*domain.User, error) {
 return s.repo.GetUserByID(ctx, id)
}
func (s *UserService) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
 return s.repo.GetUserByEmail(ctx, email)
}
func (s *UserService) UpdateUserByID(ctx context.Context, id uint, input *domain.User) error {
 user, err := s.repo.GetUserByID(ctx, id)
 if err != nil {
  return err
 }
 if input.Name != "" {
  user.Name = input.Name
 }
 if input.Email != "" {
  user.Email = input.Email
 }
 if input.Password != "" {
  hashed, err := util.HashPassword(input.Password)
  if err != nil {
   return err
  }
  user.Password = hashed
 }
 return s.repo.UpdateUserByID(ctx, user)
}
func (s *UserService) DeleteUserByID(ctx context.Context, id uint) error {
 return s.repo.DeleteUserByID(ctx, id)
}

Password hashing lives here — never in the handler, never in the repository. The handler doesn’t know what algorithm we use; the repository doesn’t care that the field was ever a plaintext string.

Password Hashing

Before the auth service can verify credentials, it needs a way to hash passwords on registration and compare them on login. We keep this in util/password.go using bcrypt — an adaptive hashing algorithm designed specifically for passwords:

package util
import "golang.org/x/crypto/bcrypt"
func HashPassword(pass string) (string, error) {
 hashed, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
 if err != nil {
  return "", err
 }
 return string(hashed), nil
}
func ComparePassword(hashed, pass string) error {
 return bcrypt.CompareHashAndPassword([]byte(hashed), []byte(pass))
}

bcrypt.DefaultCost sets the work factor to 10, meaning bcrypt runs its hashing algorithm 2¹⁰ = 1024 times internally. This deliberate slowness is the point — it makes brute-forcing a leaked password database computationally expensive. If your threat model demands stronger resistance you can raise the cost, at the expense of slightly slower login responses.

ComparePassword uses bcrypt.CompareHashAndPassword rather than a plain equality check. This is important because bcrypt embeds the salt directly in the hash string, so the comparison function knows how to extract and reuse it. A naive hash(input) == storedHash check would always fail since a new random salt would be generated each time.

Handler

The handler is the HTTP boundary. It binds incoming JSON into request structs, calls the service, and maps the result to a response. We define dedicated request and response types here so the domain struct is never directly exposed over the wire — this gives us control over exactly what fields clients can send and receive:

package handler
import (
 "net/http"
 "strconv"
 "github.com/gin-gonic/gin"
 "github.com/yehezkiel1086/go-jwt-auth/internal/core/domain"
 "github.com/yehezkiel1086/go-jwt-auth/internal/core/port"
)
type UserHandler struct {
 svc port.UserService
}
func NewUserHandler(svc port.UserService) *UserHandler {
 return &UserHandler{svc: svc}
}
type createUserReq struct {
 Name     string `json:"name" binding:"required"`
 Email    string `json:"email" binding:"required,email"`
 Password string `json:"password" binding:"required,min=8"`
}
type updateUserReq struct {
 Email    string `json:"email" binding:"omitempty,email"`
 Password string `json:"password" binding:"omitempty,min=8"`
}
type userRes struct {
 ID    uint   `json:"id"`
 Name string `json:"name"`
 Email string `json:"email"`
 Role domain.Role `json:"role"`
}
func (h *UserHandler) CreateUser(c *gin.Context) {
 var req createUserReq
 if err := c.ShouldBindJSON(&req); err != nil {
  c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  return
 }
 user := &domain.User{
  Name:     req.Name,
  Email:    req.Email,
  Password: req.Password,
 }
 if err := h.svc.CreateUser(c.Request.Context(), user); err != nil {
  c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  return
 }
 c.JSON(http.StatusCreated, gin.H{"message": "user created successfully"})
}
func (h *UserHandler) GetUsers(c *gin.Context) {
 users, err := h.svc.GetUsers(c.Request.Context())
 if err != nil {
  c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  return
 }
 res := make([]userRes, len(users))
 for i, u := range users {
  res[i] = toUserRes(&u)
 }
 c.JSON(http.StatusOK, res)
}
func (h *UserHandler) GetUserByID(c *gin.Context) {
 id, ok := parseID(c)
 if !ok {
  return
 }
 user, err := h.svc.GetUserByID(c.Request.Context(), id)
 if err != nil {
  c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
  return
 }
 c.JSON(http.StatusOK, toUserRes(user))
}
func (h *UserHandler) GetUserByEmail(c *gin.Context) {
 email := c.Param("email")
 if email == "" {
  c.JSON(http.StatusBadRequest, gin.H{"error": "email parameter is required"})
  return
 }
 user, err := h.svc.GetUserByEmail(c.Request.Context(), email)
 if err != nil {
  c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
  return
 }
 c.JSON(http.StatusOK, toUserRes(user))
}
func (h *UserHandler) UpdateUserByID(c *gin.Context) {
 id, ok := parseID(c)
 if !ok {
  return
 }
 var req updateUserReq
 if err := c.ShouldBindJSON(&req); err != nil {
  c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  return
 }
 input := &domain.User{
  Email:    req.Email,
  Password: req.Password,
 }
 if err := h.svc.UpdateUserByID(c.Request.Context(), id, input); err != nil {
  c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  return
 }
 c.JSON(http.StatusOK, gin.H{"message": "user updated successfully"})
}
func (h *UserHandler) DeleteUserByID(c *gin.Context) {
 id, ok := parseID(c)
 if !ok {
  return
 }
 if err := h.svc.DeleteUserByID(c.Request.Context(), id); err != nil {
  c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  return
 }
 c.JSON(http.StatusOK, gin.H{"message": "user deleted successfully"})
}
func toUserRes(user *domain.User) userRes {
 return userRes{
  ID:    user.ID,
  Name: user.Name,
  Email: user.Email,
  Role: user.Role,
 }
}
func parseID(c *gin.Context) (uint, bool) {
 id, err := strconv.ParseUint(c.Param("id"), 10, 32)
 if err != nil {
  c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
  return 0, false
 }
 return uint(id), true
}

The userRes type deliberately omits Password — there's no circumstance where a hashed password should travel over the wire in a response. The updateUserReq uses omitempty validators so clients can send only the fields they want to change, and empty fields are simply skipped by the service.

The parseID helper is a small but useful pattern — it centralises the strconv.ParseUint boilerplate and writes the 400 response in one place so every handler that needs an ID stays clean:

func parseID(c *gin.Context) (uint, bool) {
    id, err := strconv.ParseUint(c.Param("id"), 10, 32)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
        return 0, false
    }
    return uint(id), true
}

The full set of user endpoints this gives us:

Method Path Description POST /api/v1/users Create a new user GET /api/v1/users List all users GET /api/v1/users/:id Get user by ID GET /api/v1/users/email/:email Get user by email PUT /api/v1/users/:id Update user by ID DELETE /api/v1/users/:id Delete user by ID

We’ll wire these routes up — along with authentication middleware and rate limiting — in the router section coming up next.

Authentication

This is the core of the article. We’ll build the full JWT authentication flow: login, token rotation on refresh, logout with revocation, and the middleware stack that protects every route.

JWT Claims

Our claims domain/jwt.go struct extends jwt.RegisteredClaims with the fields we need available on every authenticated request — user ID, role, and email. These get embedded directly into the signed token so the server never needs a database round-trip just to identify the caller:

package domain
import "github.com/golang-jwt/jwt/v5"
type JWTClaims struct {
 UserID uint `json:"user_id"`
 Role   Role `json:"role"`
 Email string `json:"email"`
 jwt.RegisteredClaims
}

JWT Utilities

All token operations live in util/jwt.go. The tokenDuration helper centralises the mapping from token type to signing key and TTL — this means config is read in exactly one place, and GenerateToken / ParseToken stay clean:

func tokenDuration(tokenType string, conf *config.JWT) ([]byte, time.Duration, error) {
    switch tokenType {
    case "access":
        d, _ := strconv.Atoi(conf.AccessTokenDuration)
        return []byte(conf.AccessTokenSecret), time.Duration(d) * time.Minute, nil
    case "refresh":
        d, _ := strconv.Atoi(conf.RefreshTokenDuration)
        return []byte(conf.RefreshTokenSecret), time.Duration(d) * time.Hour * 24, nil
    default:
        return nil, 0, fmt.Errorf("invalid token type: %s", tokenType)
    }
}

We expose two generation functions. GenerateTokenWithTTL is the canonical one — it returns the signed token string alongside its TTL so the caller can store them together. GenerateToken is a convenience wrapper for when the TTL isn't needed:

func GenerateTokenWithTTL(tokenType string, user *domain.User, conf *config.JWT) (string, time.Duration, error) {
    signingKey, duration, err := tokenDuration(tokenType, conf)
    if err != nil {
        return "", 0, err
    }
    claims := &domain.JWTClaims{
        UserID: user.ID,
        Email:  user.Email,
        Role:   user.Role,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(duration)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
        },
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    signed, err := token.SignedString(signingKey)
    return signed, duration, err
}

ParseToken validates the signature and algorithm before returning the claims. The explicit SigningMethodHMAC check defends against the "alg: none" attack — a real vulnerability where some libraries accept unsigned tokens if the algorithm header is set to none:

func ParseToken(tokenString string, secret string) (*domain.JWTClaims, error) {
    claims := &domain.JWTClaims{}
    token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
        if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
        }
        return []byte(secret), nil
    })
    if err != nil || !token.Valid {
        return nil, fmt.Errorf("invalid token")
    }
    return claims, nil
}

The complete util/jwt.go:

package util
import (
 "fmt"
 "strconv"
 "time"
 "github.com/golang-jwt/jwt/v5"
 "github.com/yehezkiel1086/go-jwt-auth/internal/adapter/config"
 "github.com/yehezkiel1086/go-jwt-auth/internal/core/domain"
)
func tokenDuration(tokenType string, conf *config.JWT) ([]byte, time.Duration, error) {
 switch tokenType {
 case "access":
  d, _ := strconv.Atoi(conf.AccessTokenDuration)
  return []byte(conf.AccessTokenSecret), time.Duration(d) * time.Minute, nil
 case "refresh":
  d, _ := strconv.Atoi(conf.RefreshTokenDuration)
  return []byte(conf.RefreshTokenSecret), time.Duration(d) * time.Hour * 24, nil
 default:
  return nil, 0, fmt.Errorf("invalid token type: %s", tokenType)
 }
}
func GenerateToken(tokenType string, user *domain.User, conf *config.JWT) (string, error) {
 token, _, err := GenerateTokenWithTTL(tokenType, user, conf)
 return token, err
}
func GenerateTokenWithTTL(tokenType string, user *domain.User, conf *config.JWT) (string, time.Duration, error) {
 signingKey, duration, err := tokenDuration(tokenType, conf)
 if err != nil {
  return "", 0, err
 }
 claims := &domain.JWTClaims{
  UserID: user.ID,
  Email:  user.Email,
  Role:   user.Role,
  RegisteredClaims: jwt.RegisteredClaims{
   ExpiresAt: jwt.NewNumericDate(time.Now().Add(duration)),
   IssuedAt:  jwt.NewNumericDate(time.Now()),
  },
 }
 token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
 signed, err := token.SignedString(signingKey)
 return signed, duration, err
}
func ParseToken(tokenString string, secret string) (*domain.JWTClaims, error) {
 claims := &domain.JWTClaims{}
 token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
  if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
   return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
  }
  return []byte(secret), nil
 })
 if err != nil || !token.Valid {
  return nil, fmt.Errorf("invalid token")
 }
 return claims, nil
}

Auth Service

The service layer is where the security decisions happen. Let’s walk through each method.

Login verifies credentials, issues both tokens, and persists the refresh token in Redis keyed by user ID:

func (s *AuthService) Login(ctx context.Context, email, password string) (string, string, error) {
    user, err := s.userRepo.GetUserByEmail(ctx, email)
    if err != nil {
        return "", "", errors.New("invalid credentials")
    }
    if err := util.ComparePassword(user.Password, password); err != nil {
        return "", "", errors.New("invalid credentials")
    }
    accessToken, err := util.GenerateToken("access", user, s.conf)
    if err != nil {
        return "", "", err
    }
    refreshToken, ttl, err := util.GenerateTokenWithTTL("refresh", user, s.conf)
    if err != nil {
        return "", "", err
    }
    if err := s.storeRefreshToken(ctx, user.ID, refreshToken, ttl); err != nil {
        return "", "", err
    }
    return accessToken, refreshToken, nil
}

Notice we return the same "invalid credentials" error whether the email doesn't exist or the password is wrong. This is intentional — revealing which one failed would let an attacker enumerate valid email addresses.

RefreshToken is where token rotation lives. The incoming token is validated against both its signature and the value stored in Redis. If they don’t match, it means the token has already been used once — a strong signal of a replay attack. In that case we immediately revoke the stored token for that user, forcing a full re-login:

func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (string, string, error) {
    claims, err := util.ParseToken(refreshToken, s.conf.RefreshTokenSecret)
    if err != nil {
        return "", "", errors.New("invalid refresh token")
    }
    stored, err := s.cache.Get(ctx, s.refreshTokenCacheKey(claims.UserID))
    if err != nil {
        return "", "", errors.New("refresh token expired or not found")
    }
    // token doesn't match — possible reuse attack
    if string(stored) != refreshToken {
        s.revokeRefreshToken(ctx, claims.UserID)
        return "", "", errors.New("refresh token reuse detected")
    }
    // revoke old, issue new
    s.revokeRefreshToken(ctx, claims.UserID)
    accessToken, err := util.GenerateToken("access", user, s.conf)
    newRefreshToken, ttl, err := util.GenerateTokenWithTTL("refresh", user, s.conf)
    s.storeRefreshToken(ctx, user.ID, newRefreshToken, ttl)
    return accessToken, newRefreshToken, nil
}

This is refresh token rotation — every successful refresh issues a brand new refresh token and invalidates the previous one. A leaked refresh token can only be used once before it’s detected and killed.

Logout reads the user’s identity from the access token and deletes their refresh token from Redis. Because refresh tokens live in Redis with a TTL, they’re automatically cleaned up even if a user never explicitly logs out:

func (s *AuthService) Logout(ctx context.Context, accessToken string) error {
    claims, err := util.ParseToken(accessToken, s.conf.AccessTokenSecret)
    if err != nil {
        return errors.New("invalid access token")
    }
    return s.revokeRefreshToken(ctx, claims.UserID)
}

Here’s the complete service/auth.go:

package service
import (
 "context"
 "errors"
 "fmt"
 "time"
 "github.com/yehezkiel1086/go-jwt-auth/internal/adapter/config"
 "github.com/yehezkiel1086/go-jwt-auth/internal/adapter/storage/redis"
 "github.com/yehezkiel1086/go-jwt-auth/internal/core/port"
 "github.com/yehezkiel1086/go-jwt-auth/internal/core/util"
)
type AuthService struct {
 conf     *config.JWT
 cache    *redis.Redis
 userRepo port.UserRepository
}
func NewAuthService(conf *config.JWT, cache *redis.Redis, userRepo port.UserRepository) *AuthService {
 return &AuthService{conf: conf, cache: cache, userRepo: userRepo}
}
func (s *AuthService) refreshTokenCacheKey(userID uint) string {
 return fmt.Sprintf("refresh_token:%d", userID)
}
func (s *AuthService) storeRefreshToken(ctx context.Context, userID uint, token string, ttl time.Duration) error {
 return s.cache.Set(ctx, s.refreshTokenCacheKey(userID), []byte(token), ttl)
}
func (s *AuthService) revokeRefreshToken(ctx context.Context, userID uint) error {
 return s.cache.Del(ctx, s.refreshTokenCacheKey(userID))
}
func (s *AuthService) Login(ctx context.Context, email, password string) (string, string, error) {
 user, err := s.userRepo.GetUserByEmail(ctx, email)
 if err != nil {
  return "", "", errors.New("invalid credentials")
 }
 if err := util.ComparePassword(user.Password, password); err != nil {
  return "", "", errors.New("invalid credentials")
 }
 accessToken, err := util.GenerateToken("access", user, s.conf)
 if err != nil {
  return "", "", err
 }
 refreshToken, ttl, err := util.GenerateTokenWithTTL("refresh", user, s.conf)
 if err != nil {
  return "", "", err
 }
 if err := s.storeRefreshToken(ctx, user.ID, refreshToken, ttl); err != nil {
  return "", "", err
 }
 return accessToken, refreshToken, nil
}
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (string, string, error) {
 claims, err := util.ParseToken(refreshToken, s.conf.RefreshTokenSecret)
 if err != nil {
  return "", "", errors.New("invalid refresh token")
 }
 cacheKey := s.refreshTokenCacheKey(claims.UserID)
 stored, err := s.cache.Get(ctx, cacheKey)
 if err != nil {
  return "", "", errors.New("refresh token expired or not found")
 }
 // reject if token doesn't match — possible reuse attack
 if string(stored) != refreshToken {
  // revoke stored token as a precaution
  s.revokeRefreshToken(ctx, claims.UserID)
  return "", "", errors.New("refresh token reuse detected")
 }
 user, err := s.userRepo.GetUserByID(ctx, claims.UserID)
 if err != nil {
  return "", "", errors.New("user not found")
 }
 // revoke old refresh token before issuing new one
 if err := s.revokeRefreshToken(ctx, claims.UserID); err != nil {
  return "", "", err
 }
 accessToken, err := util.GenerateToken("access", user, s.conf)
 if err != nil {
  return "", "", err
 }
 newRefreshToken, ttl, err := util.GenerateTokenWithTTL("refresh", user, s.conf)
 if err != nil {
  return "", "", err
 }
 if err := s.storeRefreshToken(ctx, user.ID, newRefreshToken, ttl); err != nil {
  return "", "", err
 }
 return accessToken, newRefreshToken, nil
}
func (s *AuthService) Logout(ctx context.Context, accessToken string) error {
 claims, err := util.ParseToken(accessToken, s.conf.AccessTokenSecret)
 if err != nil {
  return errors.New("invalid access token")
 }
 return s.revokeRefreshToken(ctx, claims.UserID)
}

Auth Handler

The handler sets tokens as HttpOnly cookies rather than returning them in the response body. This means JavaScript running in the browser can never read them — the primary defence against XSS token theft:

func (h *AuthHandler) Login(c *gin.Context) {
    // ...validate and call service...
    secure := h.isProduction()
    host   := h.cookieHost()
    c.SetCookie("access_token",  accessToken,  15*60,       "/",                    host, secure, true)
    c.SetCookie("refresh_token", refreshToken, 7*24*60*60,  "/api/v1/auth/refresh", host, secure, true)
    c.JSON(http.StatusOK, gin.H{"message": "login successful"})
}

The refresh token cookie’s Path is set to /api/v1/auth/refresh — a narrow scope that means the browser will only send it on requests to that exact path. Even if the access token cookie were somehow compromised, the refresh token is never sent anywhere else.

The secure flag is derived from APP_ENV at runtime. In development it's false so cookies work over plain HTTP; in production it forces HTTPS-only transmission.

Logout clears both cookies by setting their MaxAge to -1:

func (h *AuthHandler) Logout(c *gin.Context) {
    // ...revoke server-side...
    c.SetCookie("access_token",  "", -1, "/",                    host, secure, true)
    c.SetCookie("refresh_token", "", -1, "/api/v1/auth/refresh", host, secure, true)
    c.JSON(http.StatusOK, gin.H{"message": "logout successful"})
}

The complete handler/auth.go:

package handler
import (
 "net/http"
 "github.com/gin-gonic/gin"
 "github.com/yehezkiel1086/go-jwt-auth/internal/adapter/config"
 "github.com/yehezkiel1086/go-jwt-auth/internal/core/port"
)
type AuthHandler struct {
 svc  port.AuthService
 conf *config.JWT
 app  *config.App
}
func NewAuthHandler(app *config.App, conf *config.JWT, svc port.AuthService) *AuthHandler {
 return &AuthHandler{svc: svc, conf: conf, app: app}
}
type loginReq struct {
 Email    string `json:"email" binding:"required,email"`
 Password string `json:"password" binding:"required"`
}
func (h *AuthHandler) isProduction() bool {
 return h.app.Env == "production"
}
func (h *AuthHandler) cookieHost() string {
 if h.isProduction() {
  return h.app.Host
 }
 return ""
}
func (h *AuthHandler) Login(c *gin.Context) {
 var req loginReq
 if err := c.ShouldBindJSON(&req); err != nil {
  c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  return
 }
 accessToken, refreshToken, err := h.svc.Login(c.Request.Context(), req.Email, req.Password)
 if err != nil {
  c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
  return
 }
 secure := h.isProduction()
 host := h.cookieHost()
 c.SetCookie("access_token", accessToken, 15*60, "/", host, secure, true)
 c.SetCookie("refresh_token", refreshToken, 7*24*60*60, "/api/v1/auth/refresh", host, secure, true)
 c.JSON(http.StatusOK, gin.H{"message": "login successful"})
}
func (h *AuthHandler) RefreshToken(c *gin.Context) {
 refreshToken, err := c.Cookie("refresh_token")
 if err != nil {
  c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token not found"})
  return
 }
 accessToken, newRefreshToken, err := h.svc.RefreshToken(c.Request.Context(), refreshToken)
 if err != nil {
  c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
  return
 }
 secure := h.isProduction()
 host := h.cookieHost()
 c.SetCookie("access_token", accessToken, 15*60, "/", host, secure, true)
 c.SetCookie("refresh_token", newRefreshToken, 7*24*60*60, "/api/v1/auth/refresh", host, secure, true)
 c.JSON(http.StatusOK, gin.H{"message": "token refreshed"})
}
func (h *AuthHandler) Logout(c *gin.Context) {
 accessToken, err := c.Cookie("access_token")
 if err != nil {
  c.JSON(http.StatusUnauthorized, gin.H{"error": "access token not found"})
  return
 }
 if err := h.svc.Logout(c.Request.Context(), accessToken); err != nil {
  c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
  return
 }
 secure := h.isProduction()
 host := h.cookieHost()
 c.SetCookie("access_token", "", -1, "/", host, secure, true)
 c.SetCookie("refresh_token", "", -1, "/api/v1/auth/refresh", host, secure, true)
 c.JSON(http.StatusOK, gin.H{"message": "logout successful"})
}

Middleware

With tokens in place, we need middleware to enforce them. Our middleware stack has five layers, each responsible for one concern.

**AuthMiddleware** reads the access token cookie, validates it, and injects the claims into the Gin context so downstream handlers can read userID, userRole, and userEmail without touching a database:

func AuthMiddleware(conf *config.JWT) gin.HandlerFunc {
    return func(c *gin.Context) {
        tokenString, err := c.Cookie("access_token")
        if err != nil {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized: no token provided"})
            return
        }
        claims, err := util.ParseToken(tokenString, conf.AccessTokenSecret)
        if err != nil {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized: " + err.Error()})
            return
        }
        c.Set("userID",    claims.UserID)
        c.Set("userEmail", claims.Email)
        c.Set("userRole",  claims.Role)
        c.Next()
    }
}

**RoleMiddleware** enforces RBAC — it checks the role set by AuthMiddleware and aborts if it doesn't match the required role. It must run after AuthMiddleware in the chain:

func RoleMiddleware(role domain.Role) gin.HandlerFunc {
    return func(c *gin.Context) {
        userRole, exists := c.Get("userRole")
        if !exists || userRole.(domain.Role) != role {
            c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "forbidden: insufficient privileges"})
            return
        }
        c.Next()
    }
}

**SelfOrAdminMiddleware** handles the common pattern where a user can modify their own resource, but an admin can modify anyone's. It compares the :id URL parameter against the authenticated userID, bypassing the check entirely for admins:

func SelfOrAdminMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        userRole, _ := c.Get("userRole")
        if userRole.(domain.Role) == domain.AdminRole {
            c.Next()
            return
        }
        paramID, err := strconv.ParseUint(c.Param("id"), 10, 32)
        if err != nil {
            c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
            return
        }
        userID, exists := c.Get("userID")
        if !exists || userID.(uint) != uint(paramID) {
            c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "forbidden: access denied"})
            return
        }
        c.Next()
    }
}

**RateLimitMiddleware** caps the number of requests a client can make in a time window. If the user is authenticated it keys by userID; otherwise it falls back to the client IP. The remaining request count is written to the X-RateLimit-Remaining response header so clients can adapt their behaviour without waiting for a 429:

func RateLimitMiddleware(rl *util.RateLimiter) gin.HandlerFunc {
    return func(c *gin.Context) {
        key := c.ClientIP()
        if userID, exists := c.Get("userID"); exists {
            key = strconv.FormatUint(uint64(userID.(uint)), 10)
        }
        allowed, remaining, err := rl.Allow(c.Request.Context(), key)
        if err != nil {
            c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "rate limiter error"})
            return
        }
        c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining))
        if !allowed {
            c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "too many requests, please try again later"})
            return
        }
        c.Next()
    }
}

**SecureHeadersMiddleware** and **CORSMiddleware** round out the stack. Secure headers defend against a class of browser-level attacks — clickjacking, MIME sniffing, XSS — while CORS enforces which origins are permitted to make cross-origin requests. The CORS implementation builds an allowedOrigins map at startup from the comma-separated CORS_ALLOWED_ORIGINS env var, so the per-request check is a single map lookup:

func CORSMiddleware(conf *config.CORS) gin.HandlerFunc {
    allowedOrigins := make(map[string]struct{}, len(conf.AllowedOrigins))
    for _, o := range conf.AllowedOrigins {
        allowedOrigins[strings.TrimSpace(o)] = struct{}{}
    }
    return func(c *gin.Context) {
        origin := c.Request.Header.Get("Origin")
        if _, ok := allowedOrigins[origin]; ok {
            c.Header("Access-Control-Allow-Origin", origin)
            c.Header("Access-Control-Allow-Credentials", "true")
            // ...
        }
        // handle preflight and continue
    }
}

With all five middleware layers in place, the next step is wiring them to routes in the router — which we’ll cover next.

The complete handler/middleware.go:

package handler
import (
 "net/http"
 "strconv"
 "strings"
 "github.com/gin-gonic/gin"
 "github.com/yehezkiel1086/go-jwt-auth/internal/adapter/config"
 "github.com/yehezkiel1086/go-jwt-auth/internal/core/domain"
 "github.com/yehezkiel1086/go-jwt-auth/internal/core/util"
)
func AuthMiddleware(conf *config.JWT) gin.HandlerFunc {
 return func(c *gin.Context) {
  tokenString, err := c.Cookie("access_token")
  if err != nil {
   c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized: no token provided"})
   c.Abort()
   return
  }
  claims, err := util.ParseToken(tokenString, conf.AccessTokenSecret)
  if err != nil {
   c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized: " + err.Error()})
   c.Abort()
   return
  }
  c.Set("userID", claims.UserID)
  c.Set("userEmail", claims.Email)
  c.Set("userRole", claims.Role)
  c.Next()
 }
}
func RoleMiddleware(role domain.Role) gin.HandlerFunc {
 return func(c *gin.Context) {
  userRole, exists := c.Get("userRole")
  if !exists || userRole.(domain.Role) != role {
   c.JSON(http.StatusForbidden, gin.H{"error": "forbidden: insufficient privileges"})
   c.Abort()
   return
  }
  c.Next()
 }
}
func SelfOrAdminMiddleware() gin.HandlerFunc {
 return func(c *gin.Context) {
  paramID, err := strconv.ParseUint(c.Param("id"), 10, 32)
  if err != nil {
   c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
   c.Abort()
   return
  }
  userRole, _ := c.Get("userRole")
  if userRole.(domain.Role) == domain.AdminRole {
   c.Next()
   return
  }
  userID, exists := c.Get("userID")
  if !exists || userID.(uint) != uint(paramID) {
   c.JSON(http.StatusForbidden, gin.H{"error": "forbidden: access denied"})
   c.Abort()
   return
  }
  c.Next()
 }
}
func RateLimitMiddleware(rl *util.RateLimiter) gin.HandlerFunc {
 return func(c *gin.Context) {
  key := c.ClientIP()
  if userID, exists := c.Get("userID"); exists {
   key = strconv.FormatUint(uint64(userID.(uint)), 10)
  }
  allowed, remaining, err := rl.Allow(c.Request.Context(), key)
  if err != nil {
   c.JSON(http.StatusInternalServerError, gin.H{"error": "rate limiter error"})
   c.Abort()
   return
  }
  c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining))
  if !allowed {
   c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many requests, please try again later"})
   c.Abort()
   return
  }
  c.Next()
 }
}
func SecureHeadersMiddleware() gin.HandlerFunc {
 return func(c *gin.Context) {
  // prevent clickjacking
  c.Header("X-Frame-Options", "DENY")
  // prevent MIME sniffing
  c.Header("X-Content-Type-Options", "nosniff")
  // enable XSS protection in older browsers
  c.Header("X-XSS-Protection", "1; mode=block")
  // force HTTPS for 1 year, include subdomains
  c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
  // restrict referrer information
  c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
  // disable browser features not needed by the API
  c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
  // content security policy for API responses
  c.Header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
  // remove server fingerprint
  c.Header("Server", "")
  c.Next()
 }
}
func CORSMiddleware(conf *config.CORS) gin.HandlerFunc {
 allowedOrigins := make(map[string]struct{}, len(conf.AllowedOrigins))
 for _, o := range conf.AllowedOrigins {
  allowedOrigins[strings.TrimSpace(o)] = struct{}{}
 }
 return func(c *gin.Context) {
  origin := c.Request.Header.Get("Origin")
  if _, ok := allowedOrigins[origin]; ok {
   c.Header("Access-Control-Allow-Origin", origin)
   c.Header("Access-Control-Allow-Credentials", "true")
   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")
   c.Header("Vary", "Origin")
  }
  if c.Request.Method == http.MethodOptions {
   c.AbortWithStatus(http.StatusNoContent)
   return
  }
  c.Next()
 }
}

Wiring It All Together in main.go

cmd/http/main.go is the composition root — it initialises every dependency in order and wires them together before starting the HTTP server. Nothing here contains business logic; it just connects the dots:

package main
import (
 "context"
 "log/slog"
 "os"
 "github.com/yehezkiel1086/go-jwt-auth/internal/adapter/config"
 "github.com/yehezkiel1086/go-jwt-auth/internal/adapter/handler"
 "github.com/yehezkiel1086/go-jwt-auth/internal/adapter/storage/postgres"
 "github.com/yehezkiel1086/go-jwt-auth/internal/adapter/storage/postgres/repository"
 "github.com/yehezkiel1086/go-jwt-auth/internal/adapter/storage/redis"
 "github.com/yehezkiel1086/go-jwt-auth/internal/core/domain"
 "github.com/yehezkiel1086/go-jwt-auth/internal/core/service"
)
func handleError(msg string, err error) {
 if err != nil {
  slog.Error(msg, "error", err)
  os.Exit(1)
 }
}
func main() {
 conf, err := config.New()
 handleError("failed to load .env configs", err)
 slog.Info(".env configs loaded successfully", "app", conf.App.Name, "env", conf.App.Env)
 ctx := context.Background()
 db, err := postgres.New(ctx, conf.DB)
 handleError("failed to init db", err)
 slog.Info("db initialized successfully")
 err = db.Migrate(&domain.User{})
 handleError("failed to migrate db", err)
 slog.Info("db migrated successfully")
 cache, err := redis.New(ctx, conf.Redis)
 handleError("failed to init redis", err)
 slog.Info("redis initialized successfully")
 userRepo := repository.NewUserRepository(db)
 userSvc := service.NewUserService(userRepo)
 userHandler := handler.NewUserHandler(userSvc)
 authSvc := service.NewAuthService(conf.JWT, cache, userRepo)
 authHandler := handler.NewAuthHandler(conf.App, conf.JWT, authSvc)
 r, err := handler.New(conf.JWT, conf.CORS, cache, userHandler, authHandler)
 handleError("failed to init router", err)
 slog.Info("router initialized successfully")
 err = r.Run(conf.HTTP)
 handleError("failed to run server", err)
}

The dependency order matters here: the repository depends on the database, the service depends on the repository, and the handler depends on the service. Each layer only knows about the interface defined in port/ — never the concrete implementation beneath it.

With the scaffolding in place, run task compose:up to start Postgres and Redis, then task dev to start the server with hot reload. You should see the startup logs confirming each layer initialised successfully.

Here’s the closing section:

Wrapping Up

We’ve covered a lot of ground. Starting from a blank Go module, we built a production-ready authentication system layer by layer — and it’s worth taking a step back to appreciate what each piece is actually doing for us.

The dual-token strategy separates two concerns that often get conflated. The access token is short-lived (15 minutes) and stateless — the server validates it by checking the signature alone, with no database or cache hit. The refresh token is long-lived and stateful — it’s stored in Redis and can be revoked instantly. This gives us the performance of stateless auth without sacrificing the ability to kick a user out.

Token rotation means every successful refresh invalidates the previous refresh token and issues a new one. If a refresh token is stolen and used by an attacker, the legitimate user’s next refresh attempt will detect the mismatch and immediately revoke the token — limiting the blast radius of a leak to a single refresh window.

HttpOnly cookies keep both tokens out of reach of JavaScript entirely. Combined with the narrow Path scope on the refresh token cookie, a successful XSS attack against your frontend gains nothing — the tokens are never accessible from document.cookie.

Rate limiting keyed by user ID and IP means authenticated users get their own independent limit, and unauthenticated requests are throttled per IP — protecting the login endpoint from credential stuffing without punishing legitimate users.

The middleware stack — auth, RBAC, self-or-admin, rate limit, secure headers, CORS — composes cleanly because each layer has one job. You can apply them selectively per route group, swap any one out independently, and test them in isolation.

What You Could Add Next

This system is solid, but production deployments often call for a few more layers:

  • Refresh token families — instead of one stored token per user, track a chain of issued tokens. Any out-of-order use invalidates the entire family, giving even stronger replay protection.
  • Access token blocklist — right now a logged-out access token is still technically valid until it expires. Adding a Redis blocklist for recently revoked access tokens closes that window, at the cost of one cache read per request.
  • Audit logging — record login attempts, failures, token refreshes, and logouts with timestamps and IP addresses. Invaluable for incident response.
  • MFA — a second factor at login makes stolen credentials significantly less useful.
  • Refresh token absolute expiry — even with rotation, a refresh token that keeps getting used will live forever. An IssuedAt check on the original token can enforce a hard maximum session length regardless of activity.

Final Thoughts

The full source code for this project is available at github.com/yehezkiel1086/go-jwt-auth. If you found this useful, feel free to leave a comment or share it with someone building their first Go backend. And if you spot something that could be improved — a bug, a missing edge case, a better approach — open an issue. Security code benefits more than most from a second pair of eyes.