How to Implement JWT Authentication in Go

JSON Web Tokens (JWT) solve a fundamental problem in distributed systems: how do you authenticate users without maintaining server-side session state? A JWT is a self-contained token with three parts...

Key Insights

  • JWT authentication enables stateless, scalable authentication by encoding user data directly in cryptographically signed tokens that clients store and send with each request
  • The golang-jwt/jwt library provides production-ready token generation and validation, but you must implement proper expiration handling, secure secret management, and refresh token logic yourself
  • Middleware-based authentication in Go allows you to protect entire route groups with minimal code duplication while maintaining clean separation between auth logic and business logic

Understanding JWT and Why It Matters

JSON Web Tokens (JWT) solve a fundamental problem in distributed systems: how do you authenticate users without maintaining server-side session state? A JWT is a self-contained token with three parts separated by dots: a header (algorithm and token type), a payload (claims about the user), and a signature (cryptographic verification). When a user logs in, your server generates a signed token containing their identity and permissions. The client stores this token and includes it in subsequent requests. Your server validates the signature to confirm the token hasn’t been tampered with, then extracts user information directly from the payload—no database lookup required.

This stateless approach scales horizontally because any server instance can validate any token without coordinating with other servers or hitting a shared session store. It’s ideal for microservices, mobile apps, and SPAs where traditional cookie-based sessions create friction.

Setting Up Your Go Project

Start by initializing a new Go module and installing the necessary dependencies:

mkdir jwt-auth-demo
cd jwt-auth-demo
go mod init github.com/yourusername/jwt-auth-demo
go get github.com/golang-jwt/jwt/v5
go get github.com/gorilla/mux

Structure your project like this:

jwt-auth-demo/
├── main.go
├── handlers/
│   ├── auth.go
│   └── protected.go
├── middleware/
│   └── auth.go
└── utils/
    └── jwt.go

This separation keeps authentication logic isolated from your HTTP handlers and makes testing easier.

Generating JWT Tokens

Create utils/jwt.go to handle token generation. First, define a custom claims struct that embeds the standard JWT claims:

package utils

import (
    "time"
    "github.com/golang-jwt/jwt/v5"
)

var jwtSecret = []byte("your-256-bit-secret-change-this-in-production")

type Claims struct {
    UserID   uint   `json:"user_id"`
    Username string `json:"username"`
    Role     string `json:"role"`
    jwt.RegisteredClaims
}

func GenerateToken(userID uint, username, role string) (string, error) {
    expirationTime := time.Now().Add(15 * time.Minute)
    
    claims := &Claims{
        UserID:   userID,
        Username: username,
        Role:     role,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(expirationTime),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            NotBefore: jwt.NewNumericDate(time.Now()),
            Issuer:    "your-app-name",
        },
    }
    
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    tokenString, err := token.SignedString(jwtSecret)
    if err != nil {
        return "", err
    }
    
    return tokenString, nil
}

This function creates a token that expires in 15 minutes and includes both standard claims (expiration, issuer) and custom claims (user ID, username, role). The short expiration time limits the damage if a token is compromised. We’ll implement refresh tokens later to handle longer sessions.

Validating and Parsing Tokens

Token validation is where security happens. Create utils/jwt.go (continued):

func ValidateToken(tokenString string) (*Claims, error) {
    claims := &Claims{}
    
    token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
        // Verify the signing method
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, jwt.ErrSignatureInvalid
        }
        return jwtSecret, nil
    })
    
    if err != nil {
        return nil, err
    }
    
    if !token.Valid {
        return nil, jwt.ErrSignatureInvalid
    }
    
    return claims, nil
}

Now create middleware in middleware/auth.go that intercepts requests and validates tokens:

package middleware

import (
    "context"
    "net/http"
    "strings"
    "jwt-auth-demo/utils"
)

type contextKey string

const ClaimsContextKey contextKey = "claims"

func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        authHeader := r.Header.Get("Authorization")
        if authHeader == "" {
            http.Error(w, "Authorization header required", http.StatusUnauthorized)
            return
        }
        
        // Expected format: "Bearer <token>"
        parts := strings.Split(authHeader, " ")
        if len(parts) != 2 || parts[0] != "Bearer" {
            http.Error(w, "Invalid authorization header format", http.StatusUnauthorized)
            return
        }
        
        claims, err := utils.ValidateToken(parts[1])
        if err != nil {
            http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
            return
        }
        
        // Add claims to request context for handlers to use
        ctx := context.WithValue(r.Context(), ClaimsContextKey, claims)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

This middleware extracts the token from the Authorization header, validates it, and adds the claims to the request context so downstream handlers can access user information.

Building Login and Protected Routes

Create handlers/auth.go for the login endpoint:

package handlers

import (
    "encoding/json"
    "net/http"
    "jwt-auth-demo/utils"
)

type LoginRequest struct {
    Username string `json:"username"`
    Password string `json:"password"`
}

type LoginResponse struct {
    Token string `json:"token"`
}

func Login(w http.ResponseWriter, r *http.Request) {
    var req LoginRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        return
    }
    
    // In production, verify against database with hashed passwords
    if req.Username != "demo" || req.Password != "password123" {
        http.Error(w, "Invalid credentials", http.StatusUnauthorized)
        return
    }
    
    // Generate token for authenticated user
    token, err := utils.GenerateToken(1, req.Username, "user")
    if err != nil {
        http.Error(w, "Failed to generate token", http.StatusInternalServerError)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(LoginResponse{Token: token})
}

Create handlers/protected.go to demonstrate accessing user information:

package handlers

import (
    "encoding/json"
    "net/http"
    "jwt-auth-demo/middleware"
    "jwt-auth-demo/utils"
)

func ProtectedEndpoint(w http.ResponseWriter, r *http.Request) {
    claims := r.Context().Value(middleware.ClaimsContextKey).(*utils.Claims)
    
    response := map[string]interface{}{
        "message":  "You have access to this protected resource",
        "user_id":  claims.UserID,
        "username": claims.Username,
        "role":     claims.Role,
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(response)
}

Wire everything together in main.go:

package main

import (
    "log"
    "net/http"
    "jwt-auth-demo/handlers"
    "jwt-auth-demo/middleware"
    "github.com/gorilla/mux"
)

func main() {
    r := mux.NewRouter()
    
    // Public routes
    r.HandleFunc("/login", handlers.Login).Methods("POST")
    
    // Protected routes
    protected := r.PathPrefix("/api").Subrouter()
    protected.Use(middleware.AuthMiddleware)
    protected.HandleFunc("/profile", handlers.ProtectedEndpoint).Methods("GET")
    
    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", r))
}

Implementing Token Refresh

Short-lived access tokens improve security, but forcing users to re-login every 15 minutes creates terrible UX. Refresh tokens solve this. Add to utils/jwt.go:

func GenerateRefreshToken(userID uint) (string, error) {
    expirationTime := time.Now().Add(7 * 24 * time.Hour) // 7 days
    
    claims := &Claims{
        UserID: userID,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(expirationTime),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
        },
    }
    
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(jwtSecret)
}

Add a refresh endpoint in handlers/auth.go:

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

func Refresh(w http.ResponseWriter, r *http.Request) {
    var req RefreshRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request", http.StatusBadRequest)
        return
    }
    
    claims, err := utils.ValidateToken(req.RefreshToken)
    if err != nil {
        http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
        return
    }
    
    // Generate new access token
    newToken, err := utils.GenerateToken(claims.UserID, claims.Username, claims.Role)
    if err != nil {
        http.Error(w, "Failed to generate token", http.StatusInternalServerError)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(LoginResponse{Token: newToken})
}

In production, store refresh tokens in a database with the ability to revoke them. This gives you the security benefits of short-lived access tokens with the UX of long-lived sessions.

Security Best Practices

Never hardcode JWT secrets. Use environment variables:

var jwtSecret = []byte(os.Getenv("JWT_SECRET"))

Generate secrets with at least 256 bits of entropy. Always use HTTPS in production—JWTs in HTTP headers are vulnerable to interception. Set appropriate token expiration times: 15 minutes for access tokens, 7 days for refresh tokens. Store tokens in memory or httpOnly cookies on the client side, never in localStorage where they’re accessible to XSS attacks.

Consider adding a token blacklist for logout functionality. When users log out, add their token to a Redis set with TTL matching the token’s expiration. Check this blacklist in your validation middleware.

Testing Your Implementation

Create utils/jwt_test.go:

package utils

import (
    "testing"
    "time"
)

func TestGenerateAndValidateToken(t *testing.T) {
    token, err := GenerateToken(1, "testuser", "admin")
    if err != nil {
        t.Fatalf("Failed to generate token: %v", err)
    }
    
    claims, err := ValidateToken(token)
    if err != nil {
        t.Fatalf("Failed to validate token: %v", err)
    }
    
    if claims.UserID != 1 || claims.Username != "testuser" {
        t.Errorf("Claims mismatch: got %+v", claims)
    }
}

func TestExpiredToken(t *testing.T) {
    // Create token that expires immediately
    claims := &Claims{
        UserID: 1,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
        },
    }
    
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    tokenString, _ := token.SignedString(jwtSecret)
    
    _, err := ValidateToken(tokenString)
    if err == nil {
        t.Error("Expected validation to fail for expired token")
    }
}

Run tests with go test ./... to verify your implementation handles both valid and invalid tokens correctly.

JWT authentication in Go is straightforward once you understand the pieces: generate signed tokens on login, validate them in middleware, and extract user information from claims. This pattern scales effortlessly and integrates cleanly with any HTTP router. The key is implementing proper security measures around secret management, token expiration, and HTTPS enforcement.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.