Building Secure JWT Authentication in Go with PostgreSQL
A simple guide to implementing secure user authentication using JSON Web Tokens (JWT) in Go applications, featuring PostgreSQL integration, password hashing, middleware protection, and refresh token functionality.
Introduction
When building web applications, user authentication is a crucial component. JSON Web Tokens (JWT) provide a modern, stateless approach to handling user authentication. Unlike traditional session-based authentication that requires server-side storage, JWTs contain all necessary information in the token itself, signed to ensure integrity.
Think of a JWT like a secure ID card. When you log in, the server gives you this ID card (token) that contains your information and a special seal (signature). Every time you want to access a protected resource, you show this ID card, and the server can verify it’s genuine by checking the seal.
Project Setup
First, let’s set up our Go project. We’ll need several key packages:
github.com/golang-jwt/jwt/v5
: For JWT creation and validationgithub.com/lib/pq
: PostgreSQL drivergolang.org/x/crypto/bcrypt
: For secure password hashinggithub.com/gin-gonic/gin
: Web framework for building our API
# Create a new project directory and initialize Go module
mkdir auth-service
cd auth-service
go mod init auth-service
# Install required dependencies
go get -u github.com/golang-jwt/jwt/v5
go get -u github.com/lib/pq
go get -u golang.org/x/crypto/bcrypt
go get -u github.com/gin-gonic/gin
Database Setup
Our PostgreSQL database needs a table to store user information. The schema below includes:
- Unique email for identification
- Securely hashed password (never store plain text passwords!)
- Timestamps for user management
CREATE TABLE users (
id SERIAL PRIMARY KEY, -- Auto-incrementing unique identifier
email VARCHAR(255) UNIQUE NOT NULL, -- Unique email address
password_hash VARCHAR(255) NOT NULL, -- Bcrypt hashed password
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Optional: Create an index on email for faster lookups
CREATE INDEX idx_users_email ON users(email);
Project Structure
A well-organized project structure helps maintain code quality and scalability. Here’s our recommended structure with explanations:
auth-service/
├── cmd/ # Application entry points
│ └── main.go # Main application file
├── internal/ # Private application code
│ ├── config/ # Configuration management
│ │ └── config.go # App configuration
│ ├── database/ # Database interactions
│ │ └── postgres.go # PostgreSQL connection and queries
│ ├── handlers/ # HTTP request handlers
│ │ └── auth.go # Authentication endpoints
│ ├── middleware/ # HTTP middleware
│ │ └── auth.go # JWT verification middleware
│ ├── models/ # Data models
│ │ └── user.go # User model and validations
│ └── utils/ # Helper functions
│ └── password.go # Password hashing utilities
├── .env # Env variable
└── go.mod # Go module definition
Implementation
Let’s implement each component with detailed explanations:
Setup .env first
SERVER_PORT=8080
SERVER_HOST=0.0.0.0
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=yourpassword
DB_NAME=auth_service
DB_SSLMODE=disable
JWT_SECRET=your-secure-secret-key
ENV=development
1. Create config (internal/config/config.go)
package config
import (
"os"
"time"
"github.com/joho/godotenv"
)
type Config struct {
Server struct {
Port string
Host string
ReadTimeout time.Duration
WriteTimeout time.Duration
}
Database struct {
Host string
Port string
User string
Password string
DBName string
SSLMode string
}
JWT struct {
Secret string
TokenExpiry time.Duration
RefreshExpiry time.Duration
}
Environment string
}
func Load() (*Config, error) {
godotenv.Load() // Load .env if exists
cfg := &Config{}
// Server config
cfg.Server.Port = getEnv("SERVER_PORT", "8080")
cfg.Server.Host = getEnv("SERVER_HOST", "0.0.0.0")
cfg.Server.ReadTimeout = time.Second * 15
cfg.Server.WriteTimeout = time.Second * 15
// Database config
cfg.Database.Host = getEnv("DB_HOST", "localhost")
cfg.Database.Port = getEnv("DB_PORT", "5432")
cfg.Database.User = getEnv("DB_USER", "postgres")
cfg.Database.Password = getEnv("DB_PASSWORD", "")
cfg.Database.DBName = getEnv("DB_NAME", "auth_service")
cfg.Database.SSLMode = getEnv("DB_SSLMODE", "disable")
// JWT config
cfg.JWT.Secret = getEnv("JWT_SECRET", "your-secret-key")
cfg.JWT.TokenExpiry = time.Hour * 24 // 24 hours
cfg.JWT.RefreshExpiry = time.Hour * 168 // 7 days
cfg.Environment = getEnv("ENV", "development")
return cfg, nil
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func (c *Config) GetDSN() string {
return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
c.Database.Host,
c.Database.Port,
c.Database.User,
c.Database.Password,
c.Database.DBName,
c.Database.SSLMode,
)
}
2. Database Connection (internal/database/postgres.go)
The database package handles our PostgreSQL connection. It includes connection pooling and health checks:
package database
import (
"database/sql"
"time"
_ "github.com/lib/pq"
)
type Database struct {
DB *sql.DB
}
func NewDatabase(connectionString string) (*Database, error) {
// Open database connection
db, err := sql.Open("postgres", connectionString)
if err != nil {
return nil, err
}
// Configure connection pool
db.SetMaxOpenConns(25) // Limit maximum simultaneous connections
db.SetMaxIdleConns(5) // Keep some connections ready
db.SetConnMaxLifetime(5 * time.Minute) // Refresh connections periodically
// Verify connection is working
if err := db.Ping(); err != nil {
return nil, err
}
return &Database{DB: db}, nil
}
3. User Model (internal/models/user.go)
The user model defines our data structures and validation rules:
package models
import (
"time"
"regexp"
"errors"
)
// User represents our database user
type User struct {
ID int `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"` // "-" means this won't be included in JSON
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// UserLogin represents login request data
type UserLogin struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
// UserRegister represents registration request data
type UserRegister struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
// Validate checks if email format is valid
func (u *UserRegister) Validate() error {
emailRegex := regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`)
if !emailRegex.MatchString(u.Email) {
return errors.New("invalid email format")
}
return nil
}
4. Password Utils (internal/utils/password.go)
This utility package handles secure password hashing using bcrypt:
package utils
import (
"golang.org/x/crypto/bcrypt"
"errors"
)
// HashPassword converts a plain text password into a hashed version
func HashPassword(password string) (string, error) {
// Cost factor of 12 provides a good balance between security and performance
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return "", errors.New("failed to hash password")
}
return string(bytes), nil
}
// CheckPasswordHash compares a password against a hash
func CheckPasswordHash(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
// ValidatePassword checks password complexity requirements
func ValidatePassword(password string) error {
if len(password) < 8 {
return errors.New("password must be at least 8 characters")
}
// Add more password validation rules as needed
return nil
}
5. Auth Handlers (internal/handlers/auth.go)
This section implements our authentication endpoints with detailed error handling and security measures:
package handlers
import (
"net/http"
"time"
"database/sql"
"auth-service/internal/models"
"auth-service/internal/utils"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
type AuthHandler struct {
db *database.Database
jwtSecret []byte
// Add token expiration configuration
tokenExpiration time.Duration
}
// NewAuthHandler creates a new authentication handler
func NewAuthHandler(db *database.Database, jwtSecret []byte) *AuthHandler {
return &AuthHandler{
db: db,
jwtSecret: jwtSecret,
tokenExpiration: 24 * time.Hour, // Default 24 hour expiration
}
}
// Register handles user registration
func (h *AuthHandler) Register(c *gin.Context) {
var user models.UserRegister
// Validate input JSON
if err := c.ShouldBindJSON(&user); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid input format",
"details": err.Error(),
})
return
}
// Additional validation
if err := user.Validate(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check if user already exists
var exists bool
err := h.db.DB.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)",
user.Email).Scan(&exists)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
if exists {
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
return
}
// Hash password
hashedPassword, err := utils.HashPassword(user.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password processing failed"})
return
}
// Insert user with transaction
tx, err := h.db.DB.Begin()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Transaction start failed"})
return
}
var id int
err = tx.QueryRow(`
INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING id`,
user.Email, hashedPassword,
).Scan(&id)
if err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "User creation failed"})
return
}
if err = tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Transaction commit failed"})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "User registered successfully",
"user_id": id,
})
}
// Login handles user authentication and JWT generation
func (h *AuthHandler) Login(c *gin.Context) {
var login models.UserLogin
if err := c.ShouldBindJSON(&login); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid login data"})
return
}
// Get user from database
var user models.User
err := h.db.DB.QueryRow(`
SELECT id, email, password_hash
FROM users
WHERE email = $1`,
login.Email,
).Scan(&user.ID, &user.Email, &user.PasswordHash)
if err == sql.ErrNoRows {
// Don't specify whether email or password was wrong
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Login process failed"})
return
}
// Verify password
if !utils.CheckPasswordHash(login.Password, user.PasswordHash) {
// Use same message as above for security
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
// Generate JWT with claims
now := time.Now()
claims := jwt.MapClaims{
"user_id": user.ID,
"email": user.Email,
"iat": now.Unix(),
"exp": now.Add(h.tokenExpiration).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(h.jwtSecret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Token generation failed"})
return
}
// Return token with expiration
c.JSON(http.StatusOK, gin.H{
"token": tokenString,
"expires_in": h.tokenExpiration.Seconds(),
"token_type": "Bearer",
})
}
// RefreshToken generates a new token for valid users
func (h *AuthHandler) RefreshToken(c *gin.Context) {
// Get user ID from context (set by auth middleware)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Generate new token
now := time.Now()
claims := jwt.MapClaims{
"user_id": userID,
"iat": now.Unix(),
"exp": now.Add(h.tokenExpiration).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(h.jwtSecret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Token refresh failed"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": tokenString,
"expires_in": h.tokenExpiration.Seconds(),
"token_type": "Bearer",
})
}
// Logout endpoint (optional - useful for client-side cleanup)
func (h *AuthHandler) Logout(c *gin.Context) {
// Since JWT is stateless, server-side logout isn't needed
// However, we can return instructions for the client
c.JSON(http.StatusOK, gin.H{
"message": "Successfully logged out",
"instructions": "Please remove the token from your client storage",
})
}
6. Auth Middleware (internal/middleware/auth.go)
The middleware handles JWT validation for protected routes:
package middleware
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// AuthMiddleware verifies JWT tokens in incoming requests
func AuthMiddleware(jwtSecret []byte) gin.HandlerFunc {
return func(c *gin.Context) {
// Get Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header missing"})
c.Abort()
return
}
// Check Bearer scheme
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization format"})
c.Abort()
return
}
tokenString := parts[1]
// Parse and validate token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Validate signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return jwtSecret, nil
})
if err != nil {
if err == jwt.ErrSignatureInvalid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token signature"})
} else {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
}
c.Abort()
return
}
// Extract and validate claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"})
c.Abort()
return
}
// Check token expiration
if exp, ok := claims["exp"].(float64); ok {
if time.Now().Unix() > int64(exp) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token expired"})
c.Abort()
return
}
}
// Set user information in context
c.Set("user_id", claims["user_id"])
c.Set("email", claims["email"])
c.Next()
}
}
// RateLimiter middleware to prevent brute force attacks
func RateLimiter() gin.HandlerFunc {
limiter := rate.NewLimiter(rate.Every(time.Second), 10)
return func(c *gin.Context) {
if !limiter.Allow() {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Too many requests"})
c.Abort()
return
}
c.Next()
}
}
7. Main Application Setup (cmd/main.go)
package main
import (
"log"
"auth-service/internal/config"
"auth-service/internal/database"
"auth-service/internal/handlers"
"auth-service/internal/middleware"
"github.com/gin-gonic/gin"
)
func main() {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
// Initialize database
db, err := database.NewDatabase(cfg.GetDSN())
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
defer db.DB.Close()
// Set Gin mode
if cfg.Environment == "production" {
gin.SetMode(gin.ReleaseMode)
}
// Initialize router with middleware
r := gin.New()
r.Use(gin.Recovery())
r.Use(gin.Logger())
// CORS middleware
r.Use(func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
// Initialize handlers with JWT configuration
authHandler := handlers.NewAuthHandler(db, []byte(cfg.JWT.Secret))
// Public routes
public := r.Group("/api/v1")
{
public.POST("/register", authHandler.Register)
public.POST("/login", authHandler.Login)
}
// Protected routes with JWT middleware
protected := r.Group("/api/v1")
protected.Use(middleware.AuthMiddleware([]byte(cfg.JWT.Secret)))
{
protected.POST("/refresh-token", authHandler.RefreshToken)
protected.POST("/logout", authHandler.Logout)
protected.GET("/profile", getUserProfile)
}
// Start server with configured host and port
serverAddr := cfg.Server.Host + ":" + cfg.Server.Port
log.Printf("Server starting on %s", serverAddr)
srv := &http.Server{
Addr: serverAddr,
Handler: r,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("Server failed to start:", err)
}
}
func getUserProfile(c *gin.Context) {
userID, _ := c.Get("user_id")
email, _ := c.Get("email")
c.JSON(200, gin.H{
"user_id": userID,
"email": email,
})
}
Security Best Practices
- Store JWT secret in environment variables
- Use HTTPS in production
- Implement rate limiting
- Add password complexity requirements
- Use secure password hashing (bcrypt)
- Implement token refresh mechanism
- Set appropriate token expiration
- Validate input data
- Use prepared statements for SQL queries
- Implement proper error handling
Conclusion
Implementing JWT authentication in Go applications requires careful consideration of security practices and architectural decisions. This guide demonstrated how to build a robust authentication system using PostgreSQL for data persistence, bcrypt for password hashing, and JWT for secure token management. By following these patterns and using the provided library, developers can quickly implement secure user authentication while maintaining code quality and scalability.
While this implementation provides a solid foundation, security is an ongoing process that requires regular updates and maintenance. It’s crucial to stay informed about new security vulnerabilities, regularly update dependencies, and monitor system usage for potential threats. Consider adding features like rate limiting, account lockout policies, and advanced logging as your application grows. Remember that this solution can be extended with additional security measures such as two-factor authentication or OAuth integration depending on your specific requirements.
If you’re interested in diving deeper into system design and backend development, be sure to follow me for more insights, tips, and practical examples. Together, we can explore the intricacies of creating efficient systems, optimizing database performance, and mastering the tools that drive modern applications. Join me on this journey to enhance your skills and stay updated on the latest trends in the tech world! 🚀
Read the design system in bahasa on iniakunhuda.com