Build a Go Backend with Gin, GORM, JWT and Docker
A beginner-friendly guide to building a full REST API backend for a portfolio website in Go with Gin, GORM, PostgreSQL, Redis, JWT, OAuth2 and Docker.
Reference: JWT Authentication in Golang and Go API Docs, plus the code in my own kooza-collinz-portfolio-backend repository.
backstory
When I started learning Go, I kept running into tutorials that dumped ten files of code at me with no explanation. I had no idea why the folders were arranged that way, or what each command actually did — so nothing stuck.
So when I built the backend for my portfolio site, I decided to slow all the way down and write this guide the way I would have wanted it when I was starting: one tiny step at a time, with every command explained, and every step answering three questions:
- What does it do?
- Why do it this way?
- What does it help with?
By the end of this guide, you will have built a working REST API backend with:
- User signup, login, email verification, and password reset
- JWT (JSON Web Token) authentication
- "Sign in with Google / GitHub / LinkedIn / Spotify / Discord"
- A public contact form with rate limiting
- An admin area protected by role checks
- Redis caching for fast responses
- File uploads to S3-compatible storage (MinIO) with automatic thumbnails
- An audit log that records every important action
And the best part: you'll understand every single line, because we go through it all together.
[!NOTE] This guide is intentionally long. Don't rush. If you only follow one chapter per day, that's totally fine — by the end of the week you'll have a real backend.
what we are building
We're building the backend for a personal portfolio website. The frontend (a Next.js site) talks to our backend through HTTP requests, and the backend figures out who you are, saves your messages, and sends emails on behalf of the site.
Here is the folder layout we will end up with:
kooza-collinz-portfolio-backend/
├── cache/ # Redis cache wrapper
├── config/ # Reads .env, holds app + OAuth + storage settings
├── controllers/ # Handles incoming HTTP requests
├── dtos/ # Data Transfer Objects (request/response shapes)
├── initializers/ # Sets up the database, Redis, and mailer
├── jobs/ # Background image processing
├── mail/ # Sends emails through Resend
├── middleware/ # Guards routes (auth + CORS)
├── migrations/ # Creates/updates the database tables
├── models/ # GORM models (Go structs = database tables)
├── storage/ # S3-compatible file storage + image processing
├── docker-compose.yml
├── go.mod
├── main.go
└── .envDon't worry about remembering this now. We'll create each folder in its own step, and by the time we finish, the layout will feel as natural as your own room.
The whole thing uses a layered flow. Here's a picture of how a request travels through it:
Browser / Frontend
|
v
[middleware] -> checks CORS + the JWT token (are you allowed?)
|
v
[controller] -> decides WHAT to do (save a user? send a mail?)
|
v
[models + initializers] -> talks to PostgreSQL / Redisprerequisites
Before we begin, you need a few things installed on your computer. If you're not sure whether you have them, just run each command and see if it works.
| Tool | Why you need it | How to check (run this) |
|---|---|---|
| Go (1.22 or newer) | The language we're coding in | go version |
| Docker Desktop | Runs PostgreSQL, Redis, MinIO, Mailhog for us | docker --version |
| Git | Not strictly required, but good for saving work | git --version |
| A code editor (VS Code is perfect) | Where you'll write your code | (you have this already 😄) |
[!TIP] Download Go from go.dev/dl and pick the installer for your operating system. Docker Desktop comes from docker.com.
One more tiny thing: in this guide I type commands in a terminal. On Windows you can use PowerShell; on Mac/Linux, the Terminal app. The $ at the start of a command is just a prompt symbol — you don't type it.
step 1: start the project with go mod init
First, make a folder for your project and step into it:
mkdir kooza-collinz-portfolio-backend
cd kooza-collinz-portfolio-backendNow the magic command:
go mod init github.com/watuulo-kooza/kooza-collinz-portfolio-backendWhat does it do? It creates a file called go.mod. This file is Go's way of tracking:
- the name (module path) of your project, and
- a list of all the outside packages (dependencies) your code uses.
Why this way? The module path is like your project's "full name". By convention it looks like a web address (even if we never host it there) because that makes it unique — no two projects will accidentally share the same name. From now on, when we import our own packages, we'll write them starting with this path, e.g. github.com/watuulo-kooza/kooza-collinz-portfolio-backend/models.
What does it help with? Go is strict about dependencies — it wants to know exactly what your program needs. go.mod is the shopping list, and go.sum (which gets created automatically later) is the receipt that proves every package is the right version.
[!NOTE] I used a placeholder username (
watuulo-kooza) here. Use your own GitHub username, or honestly anything you like — the path just has to be unique and consistent.
step 2: create the project folders
Go doesn't force you to use certain folders, but a tidy layout makes a big project easy to navigate. Let's create the folders we planned:
mkdir cache config controllers dtos initializers jobs mail middleware migrations models storageWhat does it do? Makes all our package folders in one go.
Why this way? Each folder has one job. When you come back to the project after a month, you'll know exactly where to look: "I need the auth logic → controllers/. I need the user struct → models/." This is called separation of concerns, and it's what keeps big codebases sane.
What does it help with? It makes the code self-documenting and, as we'll see, it maps directly onto how Go imports work — each folder becomes a separate package you can reuse.
step 3: install the pieces we depend on
Now we add the external libraries (called dependencies or packages) our backend needs. Run these one at a time:
go get github.com/gin-gonic/gin
go get gorm.io/gorm gorm.io/driver/postgres
go get github.com/joho/godotenv
go get github.com/golang-jwt/jwt/v5
go get golang.org/x/crypto
go get github.com/redis/go-redis/v9
go get github.com/gin-contrib/cors
go get golang.org/x/oauth2
go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/credentials
go get github.com/aws/aws-sdk-go-v2/service/s3
go get github.com/disintegration/imaging
go get github.com/MUKE-coder/gorm-studioWhat does it do? Downloads these libraries and records them in go.mod so they can be reused by anyone building the project (including you on a new machine).
Why these specific ones? Here's what we'll use each for:
| Package | Job in our project |
|---|---|
gin | The web framework — it maps URLs to our code |
gorm + postgres driver | Talks to PostgreSQL (the database) for us |
godotenv | Loads our secret settings from the .env file |
jwt/v5 | Creates and checks our login tokens |
x/crypto | Hashes passwords (bcrypt) so we never store them raw |
go-redis/v9 | Talks to Redis (our lightning-fast cache) |
gin-contrib/cors | Tells browsers our API is okay to call |
x/oauth2 | The "Sign in with Google/GitHub/..." magic |
| (none) | Emails are sent with Go's built-in net/http to Resend's API (no SDK needed) |
| AWS S3 SDK | Uploads files to S3 / MinIO / R2 / B2 cloud storage |
imaging | Resizes images and makes thumbnails |
gorm-studio | A free visual GUI to look inside your database |
What does it help with? Never reinvent the wheel. Password hashing, JWT signing, and database drivers are hard problems that thousands of people already solved — we just plug their solutions in.
step 4: create the .env file (your secret settings)
Every app has settings that shouldn't be hard-coded in the code: database passwords, secret keys, API keys. We store those in a file called .env at the project root.
touch .envThen open .env and paste this (change the passwords/keys to whatever you want):
# --- App settings ---
APP_NAME=Kooza Collins
APP_ENV=development
PORT=8080
APP_URL=http://localhost:3000
GIN_MODE=debug
# --- PostgreSQL (our main database) ---
POSTGRES_HOST=localhost
POSTGRES_USER=kooza_collins
POSTGRES_PASSWORD=strong-postgres-password
POSTGRES_DB=kooza_collins_db
POSTGRES_PORT=5436
POSTGRES_SSLMODE=disable
# --- JWT (login tokens) ---
SECRET_KEY=any-long-random-string-you-invent
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=168h
# --- Redis (cache) ---
REDIS_PASSWORD=strong-redis-password
REDIS_URL=redis://:strong-redis-password@localhost:6380
# --- Resend (email) ---
RESEND_API_KEY=your-resend-api-key
RESEND_FROM_EMAIL=noreply@koozacollinz.com
# --- CORS (which websites may call our API) ---
CORS_ORIGINS=http://localhost:3000,http://localhost:3001
# --- OAuth2 providers (sign in with ...) ---
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URL=http://localhost:8080/api/v1/auth/google/callback
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_REDIRECT_URL=http://localhost:8080/api/v1/auth/github/callback
# --- S3-compatible storage (MinIO locally) ---
STORAGE_ENDPOINT=http://localhost:9002
STORAGE_ACCESS_KEY=minioadmin
STORAGE_SECRET_KEY=minioadmin
STORAGE_BUCKET=kooza-collins
STORAGE_REGION=us-east-1
STORAGE_USE_SSL=false[!WARNING] Never commit
.envto Git — it contains passwords and secret keys! Add it to.gitignore(write.envon its own line). This is how secret keys leak. I got burned once, don't repeat it.
What does it do? Stores every secret and setting in one file that our Go program will read at startup.
Why this way? If secrets were written directly in the code, they'd be visible to anyone with the code, and you couldn't use different settings on your computer vs. your production server. With a .env file, your development keys live on your machine, and your real keys live on your server — same code, different settings.
What does it help with? Security (keys stay out of version control) and flexibility (change settings without touching code).
step 5: the config package — reading .env in Go
Now we write Go code to read that file. Create config/config.go:
package config
import (
"fmt"
"os"
"strings"
"time"
"github.com/joho/godotenv"
)
// Config holds every value the application needs, mapped 1:1 to .env keys.
type Config struct {
AppName string
AppEnv string
Port string
AppURL string
GinMode string
PostgresHost string
PostgresUser string
PostgresPassword string
PostgresDB string
PostgresPort string
PostgresSSLMode string
JWTSecret string
JWTAccessExpiry time.Duration
JWTRefreshExpiry time.Duration
RedisURL string
ResendAPIKey string
MailFromEmail string
CORSOrigins []string
}
// Load reads .env, maps every key into the struct, and validates
// required fields.
func Load() (*Config, error) {
_ = godotenv.Load()
jwtSecret := getEnv("SECRET_KEY", "")
accessExpiry, err := time.ParseDuration(getEnv("JWT_ACCESS_EXPIRY", "15m"))
if err != nil {
return nil, fmt.Errorf("invalid JWT_ACCESS_EXPIRY: %w", err)
}
refreshExpiry, err := time.ParseDuration(getEnv("JWT_REFRESH_EXPIRY", "168h"))
if err != nil {
return nil, fmt.Errorf("invalid JWT_REFRESH_EXPIRY: %w", err)
}
cfg := &Config{
AppName: getEnv("APP_NAME", "Kooza Collins"),
AppEnv: getEnv("APP_ENV", "development"),
Port: getEnv("PORT", "8080"),
AppURL: getEnv("APP_URL", "http://localhost:3000"),
GinMode: getEnv("GIN_MODE", "debug"),
PostgresHost: getEnv("POSTGRES_HOST", "localhost"),
PostgresUser: getEnv("POSTGRES_USER", "postgres"),
PostgresPassword: getEnv("POSTGRES_PASSWORD", "postgres"),
PostgresDB: getEnv("POSTGRES_DB", "portfolio_db"),
PostgresPort: getEnv("POSTGRES_PORT", "5432"),
PostgresSSLMode: getEnv("POSTGRES_SSLMODE", "disable"),
JWTSecret: jwtSecret,
JWTAccessExpiry: accessExpiry,
JWTRefreshExpiry: refreshExpiry,
RedisURL: getEnv("REDIS_URL", "redis://localhost:6379"),
ResendAPIKey: getEnv("RESEND_API_KEY", ""),
MailFromEmail: getEnv("RESEND_FROM_EMAIL", "noreply@localhost"),
CORSOrigins: strings.Split(getEnv("CORS_ORIGINS", "http://localhost:3000"), ","),
}
// --- Required-field validation ---
if cfg.PostgresPassword == "" {
return nil, fmt.Errorf("POSTGRES_PASSWORD is required")
}
if cfg.JWTSecret == "" {
return nil, fmt.Errorf("SECRET_KEY is required")
}
return cfg, nil
}
// getEnv returns the .env value for key, or a default if it's missing.
func getEnv(key, fallback string) string {
if val := os.Getenv(key); val != "" {
return val
}
return fallback
}What does it do? On startup, it reads every line of .env, puts the values into a tidy Config struct, and hands that struct to the rest of the app.
Why this way?
- One struct, one source of truth. The rest of the app asks
config.Configfor whatever it needs — it never reads.envitself. - Defaults. If a key is missing,
getEnvfalls back to a sensible default, so the app doesn't crash just because you forgot one line. - Validation up front. Required secrets (like
SECRET_KEY) are checked before we start the server, so a typo shows up immediately instead of confusingly later.
What does it help with? Moving settings out of the code (step 4) is only useful if the code can read them — this package is the bridge.
The OAuth piece (config/oauth.go)
We also create config/oauth.go — five tiny functions that build an oauth2.Config for each social provider. Here's what Google looks like:
package config
import "golang.org/x/oauth2"
// GoogleOAuthConfig needs these .env variables:
// GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URL
func GoogleOAuthConfig() *oauth2.Config {
return &oauth2.Config{
ClientID: getEnv("GOOGLE_CLIENT_ID", ""),
ClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
RedirectURL: getEnv("GOOGLE_REDIRECT_URL", ""),
Scopes: []string{
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
},
Endpoint: oauth2.Endpoint{
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
TokenURL: "https://oauth2.googleapis.com/token",
},
}
}(And the same pattern for GitHub, LinkedIn, Spotify, and Discord — same five fields, four values swapped.)
What does it do? Bundles the five things every "Sign in with Provider" flow needs: the app's client ID/secret, the redirect URL, the scopes (permissions we ask for), and the provider's two endpoints (where the browser goes to log in, and where our server trades a code for a token).
Why this way? Without this, we'd have to write the whole OAuth dance five times. With one package, the controllers only ever say "give me the Google config" and everything about that provider's URLs stays in one file.
What does it help with? Adding provider #6 later means writing just one more small function here — the rest of the app doesn't change at all.
step 6: initializers — connect the database, Redis, and mailer
These are the "turn everything on" helpers. In my project I call the folder initializers because it contains the initialization of our shared connections, used by all controllers.
database.go
package initializers
import (
"fmt"
"log"
"github.com/Watuulo-Richard/kooza-collins-portfolio-backend/config"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
var DB *gorm.DB
func ConnectToDB(cfg *config.Config) {
var err error
databaseUrl := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%s sslmode=%s",
cfg.PostgresHost,
cfg.PostgresUser,
cfg.PostgresPassword,
cfg.PostgresDB,
cfg.PostgresPort,
cfg.PostgresSSLMode,
)
DB, err = gorm.Open(postgres.Open(databaseUrl), &gorm.Config{})
if err != nil {
log.Fatal("Failed to connect to the DB:", err)
}
fmt.Println("Successfully connected to database!")
}What does it do? Builds a connection string from our config and opens a connection to PostgreSQL. It stores the connection in the package-level variable DB so every part of the app uses the same one.
Why this way? A database connection is expensive to create. If every request opened its own, the app would be slow. One shared DB variable (a singleton) means we connect once and reuse forever.
What does it help with? All of our controllers will write initializers.DB.Create(...) or initializers.DB.First(&user, ...) — they never worry about connecting, only about querying.
redis.go
package initializers
import (
"context"
"log"
"github.com/Watuulo-Richard/kooza-collins-portfolio-backend/config"
"github.com/redis/go-redis/v9"
)
var RedisClient *redis.Client
func ConnectToRedis(cfg *config.Config) {
opts, err := redis.ParseURL(cfg.RedisURL)
if err != nil {
log.Fatal("Failed to parse REDIS_URL: ", err)
}
RedisClient = redis.NewClient(opts)
if err := RedisClient.Ping(context.Background()).Err(); err != nil {
log.Fatal("Failed to connect to Redis: ", err)
}
log.Println("Connected to Redis")
}What does it do? Same idea as the DB, but for Redis — our in-memory cache. The Ping is a "are you alive?" check.
Why this way? Redis is PostgreSQL's speedy little sibling: data by default lives only in RAM, which is much faster than a database. We'll use it for things we read a lot (like the list of contact messages) and for rate limiting.
What does it help with? Later, controllers will write initializers.RedisClient.Get(...) and Set(...) — again without any setup burden.
mailer.go
package initializers
import (
"github.com/Watuulo-Richard/kooza-collins-portfolio-backend/config"
"github.com/Watuulo-Richard/kooza-collins-portfolio-backend/mail"
)
var Mailer *mail.Mailer
func InitMailer(cfg *config.Config) {
Mailer = mail.New(
cfg.ResendAPIKey,
cfg.MailFromEmail,
)
}What does it do? Creates our email service with our Resend API key. (We build the mail.Mailer type in step 11.)
Why this way? Everywhere the app needs to send an email — signup confirmation, password reset, contact replies — it calls initializers.Mailer.Send(...). One setup, used everywhere.
What does it help with? It keeps the emailing logic in one place (the mail package) and the wiring (API keys) in another (initializers), which is a clean split.
step 7: the models — what our data looks like
Models describe our data in Go. Each struct becomes a database table. Create models/user.go:
package models
import (
"time"
"gorm.io/gorm"
)
// Role constants (UPPERCASE — role checks compare against these exactly)
const (
RoleAdmin = "ADMIN"
RoleUser = "USER"
)
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
FirstName string `gorm:"size:255;not null" json:"first_name" binding:"required"`
LastName string `gorm:"size:255;not null" json:"last_name" binding:"required"`
Email string `gorm:"size:255;uniqueIndex;not null" json:"email" binding:"required,email"`
Password string `gorm:"size:255" json:"-"`
Role string `gorm:"size:20;default:'USER'" json:"role"`
Avatar string `gorm:"size:500" json:"avatar"`
JobTitle string `gorm:"size:255" json:"job_title"`
Bio string `gorm:"type:text" json:"bio"`
Active bool `gorm:"default:true" json:"active"`
Provider string `gorm:"size:50;default:'local'" json:"provider"`
// One of these gets set when the user signs in with that provider
GoogleID string `gorm:"size:255;index" json:"-"`
GithubID string `gorm:"size:255;index" json:"-"`
// ...LinkedinID, SpotifyID, DiscordID follow the exact same pattern...
EmailVerifiedAt *time.Time `json:"email_verified_at"`
// We store HASHES of tokens, never the tokens themselves
VerificationTokenHash string `gorm:"size:64;index" json:"-"`
VerificationTokenExpiresAt *time.Time `json:"-"`
ResetPasswordTokenHash string `gorm:"size:64;index" json:"-"`
ResetPasswordTokenExpiresAt *time.Time `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}What does it do? Defines every column of the users table as a typed Go field.
Why this way? The backtick tags gorm:"..." tell GORM how to build the SQL column (e.g. size:255, uniqueIndex, not null). And the second set of tags, json:"...", tells Gin how to name the field in JSON responses. Two tags, one struct — that's the GORM magic.
Why so many json:"-"? Those fields exist in the database but must never appear in an API response. Your password hash, your provider IDs, and your token hashes stay hidden from the outside world. Defense in depth — even if a response builder forgets, the tag strips them.
[!TIP] Notice we store only the hash of verification/reset tokens, not the raw token. If the database is ever leaked, the raw tokens can't be used to take over accounts. Same reason we store a bcrypt hash of the password, never the password.
Here's a second model, models/contact_message.go:
package models
import "time"
// ContactMessage stores submissions from the site's "Contact me" form.
type ContactMessage struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"not null" json:"name"`
Email string `gorm:"not null" json:"email"`
Message string `gorm:"type:text;not null" json:"message"`
Read bool `gorm:"not null;default:false;index" json:"read"`
Replied bool `gorm:"not null;default:false" json:"replied"`
Reply *string `gorm:"type:text" json:"reply,omitempty"`
RepliedAt *time.Time `gorm:"index" json:"replied_at,omitempty"`
CreatedAt time.Time `gorm:"not null;default:now();index" json:"created_at"`
}
func (ContactMessage) TableName() string {
return "contact_messages"
}What's with the *string / *time.Time? A pointer means "this can be empty/absent". A contact message doesn't have a reply until an admin writes one — so Reply stays nil (absent in JSON thanks to omitempty) instead of a misleading "".
And we'll also have a Project, an Upload, and an AuditLog model — same pattern, their own tables.
Why this way? GORM reads these structs and creates/matches tables automatically. We describe once, in Go, and the database follows.
What does it help with? No SQL writing needed for common cases, and the Go struct is the single source of truth — what you see in code is what's in the DB.
step 8: the DTOs — shaping what goes in and out
DTO = Data Transfer Object. They are the "request and response shapes" of our API. Create dtos/global_dtos.go:
package dtos
// SuccessResponse represents a successful API response
type SuccessResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Message string `json:"message,omitempty"`
}
// ErrorResponse represents an error API response
type ErrorResponse struct {
Success bool `json:"success"`
Error string `json:"error"`
Message string `json:"message,omitempty"`
}And dtos/user_dtos.go:
package dtos
import "time"
type CreateUserRequest struct {
FirstName string `json:"first_name" binding:"required,min=2,max=100"`
LastName string `json:"last_name" binding:"required,min=2,max=100"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6,max=100"`
}
type LoginUserRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6,max=100"`
RememberMe bool `json:"remember_me"`
}
type LoginResponse struct {
ID uint `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Token string `json:"token"`
Role string `json:"role"`
ExpiresIn int64 `json:"expires_in"`
}
type UserResponse struct {
ID uint `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Role string `json:"role"`
Avatar string `json:"avatar,omitempty"`
JobTitle string `json:"job_title,omitempty"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}What does it do? Defines exactly what a client must send (CreateUserRequest) and what we'll send back (UserResponse).
Why this way?
- The
binding:"required,email"tags give us free validation — Gin rejects a bad request with a clear error before our logic even runs. - The shape of the response is deliberately different from the database model: e.g.
LoginResponseadds aToken,UserResponseomits the password. The client shouldn't see or control the DB shape.
What does it help with? It's a contract between frontend and backend. Clear in, clear out, and validation handled for free.
step 9: the middleware — the bouncers
Middleware runs before your controller — it decides whether a request can pass or must be turned away. Create middleware/cors.go:
package middleware
import (
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func CORSMiddleware() gin.HandlerFunc {
return cors.New(cors.Config{
AllowAllOrigins: true, // Use for development
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: false,
MaxAge: 12 * time.Hour,
})
}What does it do? It's the CORS bouncer. When your browser-frontend calls the API from localhost:3000, browsers freak out unless the API says "yes, I know who you are, come in." This middleware adds those permission headers.
Why this way? Without CORS headers, your Next.js site literally can't call your API from the browser, even though the API works fine in Postman. AllowAllOrigins is fine for development; for production you'd list your real domain.
Now the important one — middleware/require_auth.go:
package middleware
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/Watuulo-Richard/kooza-collins-portfolio-backend/config"
"github.com/Watuulo-Richard/kooza-collins-portfolio-backend/dtos"
"github.com/Watuulo-Richard/kooza-collins-portfolio-backend/initializers"
"github.com/Watuulo-Richard/kooza-collins-portfolio-backend/models"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
func RequireAuthWithToken(c *gin.Context) {
// 1. Get the token from the "Authorization: Bearer <token>" header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, dtos.ErrorResponse{
Success: false,
Error: "Unauthorized - No token provided",
})
c.Abort()
return
}
// 2. Strip the "Bearer " prefix
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
c.JSON(http.StatusUnauthorized, dtos.ErrorResponse{
Success: false,
Error: "Unauthorized - Invalid token format. Use 'Bearer <token>'",
})
c.Abort()
return
}
// 3. Decode + verify the token's signature
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(config.JWTSecret), nil
})
if err != nil {
c.JSON(http.StatusUnauthorized, dtos.ErrorResponse{
Success: false,
Error: "Unauthorized - Invalid token",
})
c.Abort()
return
}
// 4. Check the payload + expiry
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, dtos.ErrorResponse{
Success: false,
Error: "Unauthorized - Invalid token claims",
})
return
}
exp, ok := claims["exp"].(float64)
if !ok || float64(time.Now().Unix()) > exp {
c.AbortWithStatusJSON(http.StatusUnauthorized, dtos.ErrorResponse{
Success: false,
Error: "Unauthorized - Token expired",
})
return
}
// 5. Pull out the user ID we stored in "sub" and load that user
subFloat, ok := claims["sub"].(float64)
if !ok || subFloat <= 0 {
c.AbortWithStatusJSON(http.StatusUnauthorized, dtos.ErrorResponse{
Success: false,
Error: "Unauthorized - Invalid token subject",
})
return
}
userID := uint(subFloat)
var user models.User
if err := initializers.DB.First(&user, "id = ?", userID).Error; err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, dtos.ErrorResponse{
Success: false,
Error: "Unauthorized - User not found",
})
return
}
// 6. Hand the user to the next handler via the context
c.Set("user", user)
c.Next() // continue to the controller
}What does it do? It gatekeeps every protected route. A request without a valid, unexpired, correctly-signed token is turned away with 401 Unauthorized. If the token checks out, the user is loaded from the DB and glued onto the request context so the controller doesn't have to look them up again.
Why this way? JWT is stateless — we don't store sessions on the server; the token itself is the proof of who you are. The middleware just verifies the proof. The sub claim holds the user ID, exp holds the expiry, and the signature guarantees nobody forged it (they'd need our SECRET_KEY).
What does it help with? One middleware = every protected endpoint is safe. Add it to a route and you've protected it.
There's also a RequireAdmin middleware in the same file — same idea, but it checks the user's Role == ADMIN, and it blocks everyone else with 403 Forbidden. Chain it right after RequireAuthWithToken.
step 10: the mail package — sending emails
Let's build the mail service our controllers will call. First mail/mailer.go:
package mail
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"net/http"
"time"
)
// Mailer sends emails via the Resend API.
type Mailer struct {
apiKey string
from string
client *http.Client
}
func New(apiKey, from string) *Mailer {
return &Mailer{
apiKey: apiKey,
from: from,
client: &http.Client{Timeout: 10 * time.Second},
}
}
// SendOptions configures an email to send.
type SendOptions struct {
To string
Subject string
Template string // name of the template in EmailTemplates
Data map[string]interface{}
}
// Send renders a template and sends the email via Resend.
func (m *Mailer) Send(ctx context.Context, opts SendOptions) error {
htmlBody, err := m.renderTemplate(opts.Template, opts.Data)
if err != nil {
return fmt.Errorf("rendering template %q: %w", opts.Template, err)
}
payload := map[string]interface{}{
"from": m.from,
"to": []string{opts.To},
"subject": opts.Subject,
"html": htmlBody,
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+m.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := m.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("resend API error (%d)", resp.StatusCode)
}
return nil
}What does it do? Wraps the Resend email API. Send calls a template name with some data, renders it to HTML, and POSTs it to Resend — which delivers it to the person's inbox.
Why this way? We don't want to set up our own mail server (huge pain — spam filters, DNS records, uptime). Resend does delivery for us; we just pass the message over their HTTP API.
What does it help with? Password resets, verification codes, contact replies — all use the same Send. And with mail templates (below), every email looks consistent and polished.
The templates (mail/templates.go)
We keep HTML email templates in a map:
package mail
var EmailTemplates = map[string]string{
"welcome": welcomeTemplate,
"password-reset": passwordResetTemplate,
"email-verification": emailVerificationTemplate,
"notification": notificationTemplate,
"contact-acknowledgment": contactAcknowledgmentTemplate,
"contact-reply": contactReplyTemplate,
}Each is a big HTML string with Go placeholders like {{.Code}} and {{.ResetURL}} that the template engine fills in:
const emailVerificationTemplate = `... <h1>Verify Your Email</h1>
<p>Hi {{.Name}}, welcome aboard! Please enter this code to verify your email:</p>
<div class="code">{{.Code}}</div>
<p>This code expires in 10 minutes...</p> ...`What does it do? Keeps every email design in one file, with values injected by Go's html/template.
Why this way? Separating content (template) from delivery (Mailer) means the controllers just say Template: "email-verification", Data: {...} — no raw HTML in controller code.
What does it help with? Reuse and consistency. The signup flow and the resend-verification flow share the same template, so both emails look identical.
step 11: the user controller — the heart of authentication
This is where the real action happens. Create controllers/user_controller.go. Let's build it in pieces.
Signup
func SignUpWithToken(c *gin.Context) {
// 1. Read + validate the incoming JSON
var request dtos.CreateUserRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, dtos.ErrorResponse{
Success: false,
Error: "Invalid input: " + err.Error(),
})
return
}
// 2. Hash the password (never store it raw!)
hashPassword, err := bcrypt.GenerateFromPassword([]byte(request.Password), 10)
if err != nil {
c.JSON(http.StatusBadRequest, dtos.ErrorResponse{
Success: false,
Error: "Failed to hash the password",
})
return // fixed: this was missing, so a hash failure used to fall through
}
// 3. Build the user row
user := models.User{
FirstName: request.FirstName,
LastName: request.LastName,
Email: request.Email,
Password: string(hashPassword),
Role: models.RoleUser,
}
// 4. Save to the database
if err := initializers.DB.Create(&user).Error; err != nil {
if strings.Contains(err.Error(), "duplicate") ||
strings.Contains(err.Error(), "unique") {
c.JSON(http.StatusConflict, dtos.ErrorResponse{
Success: false,
Error: "User with this email already exists",
})
return
}
c.JSON(http.StatusInternalServerError, dtos.ErrorResponse{
Success: false,
Error: "Failed to create user",
})
return
}
// 5. Log the action to the audit trail
// ...initializers.AuditLogger.Log(context.Background(), audit.Entry{...})...
// 6. Generate a 6-digit verification code, hash it, save the hash
rawCode, hashedCode, err := generateVerificationCode()
if err != nil { /* handle */ }
expiresAt := time.Now().Add(time.Minute * 10)
user.VerificationTokenHash = hashedCode
user.VerificationTokenExpiresAt = &expiresAt
initializers.DB.Save(&user)
// 7. Email the raw code (the hash stays in the DB)
initializers.Mailer.Send(context.Background(), mail.SendOptions{
To: user.Email,
Subject: "Verify your email",
Template: "email-verification",
Data: map[string]interface{}{
"AppName": "Kooza Collins",
"Name": user.FirstName,
"Code": rawCode,
"Year": time.Now().Year(),
},
})
c.JSON(http.StatusCreated, dtos.SuccessResponse{
Success: true,
Message: "Account created! Please check your email for a 6-digit verification code",
Data: dtos.UserResponse{
ID: user.ID, FirstName: user.FirstName,
LastName: user.LastName, Email: user.Email,
Role: user.Role,
},
})
}What does it do? Accepts first/last name, email, and password; hashes the password with bcrypt; saves the user; generates a 6-digit code; emails it to them.
Why this way?
- bcrypt hashing means even we can never read the password back — we only ever compare hashes. Cost 10 is a good balance of secure-but-not-slow.
- The duplicate check catches "email already taken" gracefully instead of crashing.
- We don't manually check passwords; bcrypt handles that at login.
Login
const (
rememberMeTTL = time.Hour * 24 * 30 // "Remember me" checked
sessionTTL = time.Hour * 24 // not checked
)
func LoginWithToken(c *gin.Context) {
var req dtos.LoginUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, dtos.ErrorResponse{Success: false, Error: "Invalid input: " + err.Error()})
return
}
// 1. Find the user by email
var user models.User
result := initializers.DB.Where("email = ?", req.Email).First(&user)
if result.Error != nil {
c.JSON(http.StatusUnauthorized, dtos.ErrorResponse{Success: false, Error: "Invalid email or password"})
return
}
// 2. Compare the submitted password against the stored hash
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, dtos.ErrorResponse{Success: false, Error: "Invalid email or password"})
return
}
// 3. Block inactive + unverified accounts
if !user.Active {
c.JSON(http.StatusForbidden, dtos.ErrorResponse{Success: false, Error: "Account is inactive"})
return
}
if !user.IsEmailVerified() {
c.JSON(http.StatusForbidden, dtos.ErrorResponse{Success: false, Error: "Email not verified"})
return
}
// 4. Build the JWT
ttl := sessionTTL
if req.RememberMe {
ttl = rememberMeTTL
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": user.ID, // subject = the user's ID
"exp": time.Now().Add(ttl).Unix(), // expiry
})
tokenString, err := token.SignedString([]byte(config.JWTSecret))
if err != nil {
c.JSON(http.StatusInternalServerError, dtos.ErrorResponse{Success: false, Error: "Failed to create token"})
return
}
// 5. Audit log, then respond with the token
c.JSON(http.StatusOK, dtos.SuccessResponse{
Success: true,
Message: "Login successful",
Data: dtos.LoginResponse{
ID: user.ID, FirstName: user.FirstName, LastName: user.LastName,
Email: user.Email, Token: tokenString, Role: user.Role,
ExpiresIn: int64(ttl.Seconds()),
},
})
}What does it do? The reverse of signup: find the user, verify the password against the hash, and if everything is good — sign a JWT with the user's ID inside. That token is what the frontend stores and sends on future requests (and what our middleware in step 9 validates).
Why sub and exp? Those are the standard JWT claim names. sub (subject) identifies the user; exp (expiry) tells every reader when it stops being valid. We also mirror the expiry in ExpiresIn so the frontend knows when to re-log-in.
[!TIP] I mark login failures with the same message — "Invalid email or password" — whether the email doesn't exist or the password is wrong. This stops attackers from using the login screen to discover which emails exist. That's called preventing user enumeration.
Validate (/validate)
func ValidateUser(c *gin.Context) {
raw, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, dtos.ErrorResponse{Success: false, Error: "Unauthorized - No user in context"})
return
}
user, ok := raw.(models.User)
if !ok {
c.JSON(http.StatusInternalServerError, dtos.ErrorResponse{Success: false, Error: "Failed to parse user from context"})
return
}
c.JSON(http.StatusOK, dtos.SuccessResponse{
Success: true,
Message: "User retrieved successfully",
Data: dtos.UserResponse{
ID: user.ID, FirstName: user.FirstName, LastName: user.LastName,
Email: user.Email, Role: user.Role, Active: user.Active,
},
})
}What does it do? Returns the logged-in user's profile. It relies on RequireAuthWithToken having already run first (we'll wire that in main.go) — the middleware put the user object into the context, and this controller just reads it back.
Why this way? The frontend calls /users/validate on page load with the stored token and asks: "is this token still good? Who am I?" If the token is invalid/expired, the middleware rejects the whole request before we get here.
Verification code + reset token helpers
The controller also has small helpers:
// generateVerificationCode creates a random 6-digit numeric code (e.g. "042817")
// and its SHA-256 hash. We email the raw code, but only store the hash.
func generateVerificationCode() (rawCode string, hashedCode string, err error) {
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "", "", err
}
rawCode = fmt.Sprintf("%06d", n.Int64()) // zero-pad so it's always 6 digits
hash := sha256.Sum256([]byte(rawCode))
hashedCode = hex.EncodeToString(hash[:])
return rawCode, hashedCode, nil
}
// generateResetToken creates a cryptographically random 32-byte value,
// hex-encodes it (the part we email), then SHA-256 hashes it (the part we store).
func generateResetToken() (rawToken string, hashedToken string, err error) {
bytes := make([]byte, 32)
if _, err = rand.Read(bytes); err != nil {
return "", "", err
}
rawToken = hex.EncodeToString(bytes)
hash := sha256.Sum256([]byte(rawToken))
hashedToken = hex.EncodeToString(hash[:])
return rawToken, hashedToken, nil
}Why %06d? So 42817 becomes "042817" — some codes legitimately start with a zero, and users need to type them correctly.
Why hash the code/token before storing? Same defense in depth as the password: if the DB leaks, neither a verification code nor a reset token can be replayed — only their irreversible hashes.
And the four flows that use them:
VerifyEmail— takes email + 6-digit code, hashes the code, compares it to the stored hash with a constant-time comparison (so timing can't leak the code), marks the account verified, clears the code (single-use), and issues the first JWT — verifying logs you in.ResendVerification— generates a fresh code (which overwrites the old, making previous codes useless), emails it. Returns the same message whether or not the account exists — no enumeration.ForgotPassword— generates a reset token, emails a link containing the raw token, stores just the hash. Also returns the identical message whether or not the account exists.ResetPassword— hashes the token from the link, finds the user by hash, checks expiry, hashes the new password, saves it, then wipes the token fields so the same link can never be reused.
step 12: contact message controller — public form + admin area
Create controllers/contact_message_controller.go. This one is great because it shows rate limiting, caching, and role protection all together.
const redisContactListKey = "contact:messages:list"
const contactListCacheTTL = 60 * time.Second
// Public rate limit for the contact form — max submissions per IP per hour
const contactRateLimitWindow = time.Hour
const contactRateLimitMax = 5
func CreateContactMessage(c *gin.Context) {
ctx := context.Background()
// --- Rate limit by IP, before touching the database ---
rateLimitKey := "contact:ratelimit:" + c.ClientIP()
count, err := initializers.RedisClient.Incr(ctx, rateLimitKey).Result()
if err == nil {
if count == 1 {
initializers.RedisClient.Expire(ctx, rateLimitKey, contactRateLimitWindow)
}
if count > contactRateLimitMax {
c.JSON(http.StatusTooManyRequests, dtos.ErrorResponse{
Success: false, Error: "Too many requests",
})
return
}
}
// --- Validate + save the message ---
var request dtos.CreateContactMessageRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, dtos.ErrorResponse{Success: false, Error: "Invalid input: " + err.Error()})
return
}
message := models.ContactMessage{
Name: request.Name, Email: request.Email, Message: request.Message,
}
if err := initializers.DB.Create(&message).Error; err != nil {
c.JSON(http.StatusInternalServerError, dtos.ErrorResponse{Success: false, Error: "Failed to save your message"})
return
}
// --- Send a "thanks!" email (fire-and-forget) ---
// ...Mailer.Send(..., Template: "contact-acknowledgment", ...)...
// --- Invalidate the cached list (it's now stale) ---
initializers.RedisClient.Del(ctx, redisContactListKey)
c.JSON(http.StatusCreated, dtos.SuccessResponse{
Success: true,
Message: "Thanks for reaching out! I'll get back to you soon",
Data: toContactMessageResponse(message),
})
}What does it do? Accepts a public contact-form submission, saves it, emails an acknowledgment, and clears the cache so the admin list refreshes.
Why rate limiting? This endpoint is public. Without the limit, one bot could fill the database with thousands of fake messages. An IP counter in Redis (INCR, with the window set only on the first hit — clever detail) makes the form free for humans but painful for spam.
Why delete the cache key after creating? The cached admin list would be missing this new message — so it's invalidated. Next read rebuilds from Postgres. Cache invalidation is the classic hard part of caching!
The admin endpoints (GetContactMessages, GetContactMessage, UpdateContactMessage, DeleteContactMessage) follow the same patterns you've already seen — DB queries, DTO responses, audit logging, cache invalidation — but they're protected.
// GetContactMessages serves the list from Redis cache when possible,
// falling back to Postgres on a miss.
func GetContactMessages(c *gin.Context) {
ctx := context.Background()
cached, err := initializers.RedisClient.Get(ctx, redisContactListKey).Result()
if err == nil {
var messages []dtos.ContactMessageResponse
if jsonError := json.Unmarshal([]byte(cached), &messages); jsonError == nil {
c.JSON(http.StatusOK, dtos.SuccessResponse{Success: true, Data: messages})
return // cache hit — Postgres never touched
}
}
// cache miss — read from Postgres
var messages []models.ContactMessage
initializers.DB.Order("created_at desc").Find(&messages)
responses := toContactMessageResponses(messages)
// store in cache for next time
if encoded, err := json.Marshal(responses); err == nil {
initializers.RedisClient.Set(ctx, redisContactListKey, encoded, contactListCacheTTL)
}
c.JSON(http.StatusOK, dtos.SuccessResponse{Success: true, Data: responses})
}What does this caching pattern do? Read from Redis (fast). If it's there — done, the database is never touched. If not, load from Postgres and populate Redis for next time. That's called cache-aside.
Why? The admin dashboard refreshes the message list constantly. Postgres is fast, but Redis is faster — and repeated refreshes never hit the DB.
step 13: the OAuth controller — "sign in with Google", etc.
Create controllers/oauth_controller.go. This is the meatiest file, so let's understand its flow:
1. User clicks "Continue with Google" → GET /api/v1/auth/google
2. OAuthLoginHandler redirects browser → Google's login/consent page
3. User approves on Google's website (we never see their Google password)
4. Google redirects back → GET /api/v1/auth/google/callback?code=...&state=...
5. OAuthCallbackHandler swaps code → gets an access token (server-to-server)
6. ...asks Google "who is this?" → fetchUserInfo(accessToken)
7. ...finds-or-creates a User row → findOrCreateOAuthUser
8. ...issues the same JWT as login → redirects browser to frontend with ?token=JWTThe clever part: the whole flow is written once, generically, and each provider just supplies its own config (step 5) and its own "who is this user?" function.
type oauthUserInfo struct {
ProviderUserID string // the provider's own unique ID for this person
Email string
FirstName string
LastName string
AvatarURL string
}
type fetchUserInfoFunc func(accessToken string) (*oauthUserInfo, error)
func OAuthLoginHandler(cfg *oauth2.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// Generate a signed "state" value — the CSRF guard
state, err := generateOAuthState()
if err != nil {
c.JSON(http.StatusInternalServerError, dtos.ErrorResponse{Success: false, Error: "Failed to start sign-in"})
return
}
// Redirect the browser to the provider's consent page
authURL := cfg.AuthCodeURL(state)
c.Redirect(http.StatusFound, authURL)
}
}What does the state do? It's our login anti-forgery lock. generateOAuthState makes random.timestamp.signature where the signature is an HMAC of the first two parts using our SECRET_KEY. When the provider bounces back, verifyOAuthState re-signs and compares — if the value wasn't issued by us, it's rejected. That blocks CSRF: nobody can start their own OAuth flow and trick a victim into signing into the attacker's account.
The callback is where the real work happens:
func OAuthCallbackHandler(provider string, cfg *oauth2.Config, fetchUserInfo fetchUserInfoFunc) gin.HandlerFunc {
return func(c *gin.Context) {
frontendURL := config.AppURL
// Step 4a: verify the state round-tripped untouched
state := c.Query("state")
if !verifyOAuthState(state) {
c.Redirect(http.StatusFound, frontendURL+"/auth/callback?error=invalid_state")
return
}
// Step 4b: the user may have hit "Cancel" instead of "Allow"
if oauthErr := c.Query("error"); oauthErr != "" {
c.Redirect(http.StatusFound, frontendURL+"/auth/callback?error="+url.QueryEscape(oauthErr))
return
}
// Step 4c: swap the temporary code for a real access token (server-to-server)
code := c.Query("code")
token, err := cfg.Exchange(context.Background(), code)
if err != nil {
c.Redirect(http.StatusFound, frontendURL+"/auth/callback?error=token_exchange_failed")
return
}
// Step 5a: ask the provider "who is this user?"
info, err := fetchUserInfo(token.AccessToken)
if err != nil || info.Email == "" {
c.Redirect(http.StatusFound, frontendURL+"/auth/callback?error=profile_fetch_failed")
return
}
// Step 5b: find their account, or create one
user, err := findOrCreateOAuthUser(provider, info)
if err != nil {
c.Redirect(http.StatusFound, frontendURL+"/auth/callback?error=account_creation_failed")
return
}
// Same deactivated-account guard as normal login
if !user.Active {
c.Redirect(http.StatusFound, frontendURL+"/auth/callback?error=account_inactive")
return
}
// Step 6a: issue the same kind of JWT as LoginWithToken
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": user.ID,
"exp": time.Now().Add(time.Hour * 24 * 30).Unix(),
})
tokenString, _ := jwtToken.SignedString([]byte(config.JWTSecret))
// Step 6b: hand the browser back to the frontend, JWT in tow
c.Redirect(http.StatusFound, frontendURL+"/auth/callback?token="+tokenString)
}
}Why redirect instead of JSON? This endpoint is visited by a browser, not a fetch() call. There's no JavaScript reading JSON — the browser physically bounces Google → us → our frontend's /auth/callback?token=... page, which reads the token and logs the user in like any other login.
The findOrCreateOAuthUser logic has a nice rule — in order:
func findOrCreateOAuthUser(provider string, info *oauthUserInfo) (*models.User, error) {
// 1. Seen this exact provider account before? → log into that row
if user, err := findUserByProviderID(provider, info.ProviderUserID); err == nil {
return user, nil
}
// 2. No? But is there already an account with this email?
// → LINK this provider to that existing row (no duplicate accounts!)
var userByEmail models.User
if err := initializers.DB.Where("email = ?", info.Email).First(&userByEmail).Error; err == nil {
setProviderID(&userByEmail, provider, info.ProviderUserID)
if userByEmail.Avatar == "" {
userByEmail.Avatar = info.AvatarURL
}
if !userByEmail.IsEmailVerified() {
now := time.Now()
userByEmail.EmailVerifiedAt = &now // provider already verified this email
}
initializers.DB.Save(&userByEmail)
return &userByEmail, nil
}
// 3. Nobody matched → brand-new person, create a fresh row
now := time.Now()
newUser := models.User{
FirstName: info.FirstName, LastName: info.LastName,
Email: info.Email, Role: models.RoleUser,
Avatar: info.AvatarURL, Active: true,
Provider: provider, EmailVerifiedAt: &now,
}
setProviderID(&newUser, provider, info.ProviderUserID)
initializers.DB.Create(&newUser)
return &newUser, nil
}What does it do? The single rule that decides how any social login maps onto your users table — reuse existing rows instead of creating confusing duplicates.
Then for each provider there's a Fetch*UserInfo function that calls the provider's "who is this?" endpoint with the access token and normalizes the reply:
func FetchGoogleUserInfo(accessToken string) (*oauthUserInfo, error) {
var raw struct {
Sub string `json:"sub"`
Email string `json:"email"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
Picture string `json:"picture"`
}
if err := fetchProviderJSON("https://www.googleapis.com/oauth2/v3/userinfo", accessToken, &raw); err != nil {
return nil, err
}
return &oauthUserInfo{
ProviderUserID: raw.Sub,
Email: raw.Email,
FirstName: raw.GivenName,
LastName: raw.FamilyName,
AvatarURL: raw.Picture,
}, nil
}(GitHub, LinkedIn, Spotify, and Discord each have their own — slightly different endpoints and JSON shapes, same pattern. GitHub even needs a second request when the user's email is private.)
step 14: the migration tool — create the tables
Everything is defined as Go structs, but the database doesn't know that yet. Our migrations/migration.go makes GORM create all the tables for us:
package main
import (
"log"
"github.com/Watuulo-Richard/kooza-collins-portfolio-backend/config"
"github.com/Watuulo-Richard/kooza-collins-portfolio-backend/initializers"
"github.com/Watuulo-Richard/kooza-collins-portfolio-backend/models"
)
func init() {
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config: ", err)
}
initializers.ConnectToDB(cfg)
}
func main() {
log.Println("Starting database migration...")
err := initializers.DB.AutoMigrate(
&models.User{},
&models.Project{},
&models.ContactMessage{},
&models.AuditLog{},
)
if err != nil {
log.Fatal("Failed to migrate database:", err)
}
log.Println("Database migration completed successfully!")
}We run it with:
go run ./migrationsWhat does it do? Connects to Postgres and creates (or updates) the tables to match our models — columns, types, indexes, and all.
Why a separate command? Auto-migrating inside the server itself works for tiny projects, but running it as a separate, explicit step means you control when schema changes happen — no surprise table changes when your server restarts.
What does it help with? Your database now has real tables: users, projects, contact_messages, audit_logs. You can check a moment later with gorm-studio (step 17) and see them.
step 15: main.go — wire everything together
Now the moment it all comes together: main.go. This file has two special functions — init() (runs settings/connections) and main() (starts the server and maps routes).
package main
import (
"log"
"github.com/MUKE-coder/gorm-studio/studio"
"github.com/Watuulo-Richard/kooza-collins-portfolio-backend/config"
"github.com/Watuulo-Richard/kooza-collins-portfolio-backend/controllers"
"github.com/Watuulo-Richard/kooza-collins-portfolio-backend/initializers"
"github.com/Watuulo-Richard/kooza-collins-portfolio-backend/middleware"
"github.com/Watuulo-Richard/kooza-collins-portfolio-backend/models"
"github.com/gin-gonic/gin"
)
func init() {
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config: ", err)
}
gin.SetMode(cfg.GinMode)
initializers.ConnectToDB(cfg)
initializers.InitAuditLogger()
initializers.ConnectToRedis(cfg)
initializers.InitMailer(cfg)
}
func main() {
router := gin.Default()
router.Use(middleware.CORSMiddleware()) // CORS for every route
v1 := router.Group("/api/v1")
{
users := v1.Group("/users")
{
users.POST("", controllers.SignUpWithToken)
users.POST("/login", controllers.LoginWithToken)
users.GET("/validate", middleware.RequireAuthWithToken, controllers.ValidateUser)
users.POST("/verify-email", controllers.VerifyEmail)
users.POST("/resend-verification", controllers.ResendVerification)
users.POST("/forgot-password", controllers.ForgotPassword)
users.POST("/reset-password", controllers.ResetPassword)
}
contact := v1.Group("/contact")
{
contact.POST("", controllers.CreateContactMessage) // PUBLIC
// ADMIN ONLY — the middleware chain protects these
contact.GET("", middleware.RequireAuthWithToken, middleware.RequireAdmin(), controllers.GetContactMessages)
contact.GET("/:id", middleware.RequireAuthWithToken, middleware.RequireAdmin(), controllers.GetContactMessage)
contact.PATCH("/:id", middleware.RequireAuthWithToken, middleware.RequireAdmin(), controllers.UpdateContactMessage)
contact.DELETE("/:id", middleware.RequireAuthWithToken, middleware.RequireAdmin(), controllers.DeleteContactMessage)
}
}
// --- OAuth (social login) ---
// IMPORTANT: the OAuth configs are built HERE, inside main(), NOT in a
// package-level var. Package-level vars run BEFORE init() loads the .env,
// so they'd always see empty client IDs. Calling the functions here,
// after init() ran, sidesteps that completely.
auth := v1.Group("/auth")
{
auth.GET("/google", controllers.OAuthLoginHandler(config.GoogleOAuthConfig()))
auth.GET("/google/callback", controllers.OAuthCallbackHandler("google", config.GoogleOAuthConfig(), controllers.FetchGoogleUserInfo))
auth.GET("/github", controllers.OAuthLoginHandler(config.GithubOAuthConfig()))
auth.GET("/github/callback", controllers.OAuthCallbackHandler("github", config.GithubOAuthConfig(), controllers.FetchGithubUserInfo))
// ...linkedin, spotify, discord follow the same two lines each...
}
// --- GORM Studio: a visual GUI for your database at /studio ---
studio.Mount(router, initializers.DB, []interface{}{
&models.User{},
&models.Project{},
&models.ContactMessage{},
&models.AuditLog{},
})
router.Run() // starts the server on PORT (default 8080)
}What does it do? Loads config, connects everything, builds the router, and attaches every endpoint to its handlers. The order matters: init() runs first, then main().
Why group routes under /api/v1? Versioning. When your API grows and you make breaking changes, you can release /api/v2 without breaking old apps still using v1. It's a cheap habit with real payoff.
Why do protected routes list two middlewares? Look at line:
contact.GET("", middleware.RequireAuthWithToken, middleware.RequireAdmin(), controllers.GetContactMessages)
Request flow: CORS (global) → RequireAuthWithToken (who are you?) → RequireAdmin (are you admin?) → controller. Layered bouncers — this is the gin way.
What about the OAuth timing gotcha? config.GoogleOAuthConfig() is called inside main() deliberately. Package-level variables at the top of the file would be evaluated before init() loads .env — meaning empty secrets. The comment in the code calls this out — it's a classic Go var vs init() ordering trap.
step 16: Docker Compose — give us PostgreSQL, Redis, MinIO, Mailhog
We could install PostgreSQL, Redis, and friends directly on our machine, but that's messy and different on every OS. Docker runs them in isolated containers with one command. Create docker-compose.yml:
services:
postgres:
image: postgres:16-alpine
profiles: ["infra"]
restart: unless-stopped
environment:
POSTGRES_USER: kooza_collins
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: kooza_collins_db
ports:
- "5436:5432" # 5436 on our machine → 5432 inside the container
volumes:
- postgres_data:/var/lib/postgresql/data # survive container restarts
healthcheck:
test: ["CMD-SHELL", "pg_isready -U kooza_collins -d kooza_collins_db"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
profiles: ["infra"]
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
ports:
- "6380:6379"
volumes:
- redisdata:/data
healthcheck:
test: ['CMD', 'redis-cli', '-a', '${REDIS_PASSWORD}', 'ping']
interval: 5s
timeout: 5s
retries: 5
minio:
image: minio/minio
profiles: ["storage"]
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9002:9000" # API
- "9003:9001" # web console
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
mailhog:
image: mailhog/mailhog # fake email inbox for development
restart: unless-stopped
ports:
- "1026:1025" # SMTP
- "8026:8025" # web UI
volumes:
postgres_data:
redisdata:
minio_data:Then start everything:
docker compose --profile infra --profile storage up -dWhat does it do? Downloads those container images (-d = run in the background/daemon) and starts each service on its port.
Why profiles? You don't always need everything. --profile infra gives Postgres + Redis (essential for the API). --profile storage adds MinIO (only for file uploads). You can start just what you need:
docker compose --profile infra up -d # api's essentials
docker compose --profile storage up -d # uploads only
docker compose --profile infra --profile storage up -d # everythingWhy named volumes? postgres_data:, redisdata:, minio_data: are named volumes — data stored outside the container. Restart or rebuild the container and your data survives. Without volumes, deleting a container deletes your database.
What's Mailhog? A fake inbox! All emails our app sends go to Mailhog instead of real recipients while developing. Open http://localhost:8026 to "receive" the verification emails and password reset links. Genius for testing.
[!TIP] Matched ports: Postgres
5436(not 5432), Redis6380(not 6379) — we deliberately pick non-default host ports so they never collide with a local install of the real Postgres/Redis. Your.envmust use these numbers!
step 17: run the server and look at the database
Run the migration (creates the tables):
go run ./migrationsStart the API:
go run .You should see the Gin banner and something like:
[GIN-debug] Listening and serving HTTP on :8080
Look at your database with GORM Studio — visit http://localhost:8080/studio. You'll get a visual GUI of your tables, completely free, mounted right inside your own app. This is genuinely one of the coolest things in the stack.
[!NOTE] If Docker services aren't running, the app will crash at startup with a connection error — that's the
log.Fatalworking as designed. Start Docker first (docker compose --profile infra up -d), or the cloud equivalents if you skipped Docker.
Let's test with curl:
# Signup
curl -X POST http://localhost:8080/api/v1/users \
-H "Content-Type: application/json" \
-d '{"first_name":"Kooza","last_name":"Collins","email":"k@gmail.com","password":"secret123"}'
# Login
curl -X POST http://localhost:8080/api/v1/users/login \
-H "Content-Type: application/json" \
-d '{"email":"k@gmail.com","password":"secret123"}'The login response contains a token. Copy it, then validate:
curl http://localhost:8080/api/v1/users/validate \
-H "Authorization: Bearer YOUR_TOKEN_HERE"What does it do? Proves the whole stack is connected: Gin receives the request, the controller runs, GORM writes to Postgres, JWT comes back, middleware validates it.
step 18: hot reload with Air (stop restarting by hand)
Typing go run . after every change is tedious. Air watches your files and restarts automatically. Install it once:
go install github.com/air-verse/air@latestThen just run:
airWhat does it do? Watches the project, and on any .go change it rebuilds and restarts the server for you in milliseconds.
Why this way? Because go run . compiles the whole app. When you're iterating on a handler, saving 5 seconds per try × 100 tries a day is 8 minutes — and Air removes the whole mental context switch of "stop, run, scroll."
If you want, add an .air.toml config to control exactly what it watches and where it puts the build output (tmp/main), like I did — but Air works out of the box with zero config.
step 19: bonus — file uploads, caching, and background jobs
These are the building blocks that make the backend genuinely full-featured. They follow every pattern you've already learned, so I'll show the shapes quickly.
storage (storage/storage.go) — S3-compatible uploads
package storage
// Storage provides S3-compatible file storage (AWS S3, MinIO, R2, B2).
func New(cfg config.StorageConfig) (*Storage, error) {
// ...loads AWS SDK with custom endpoint + static credentials...
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.UsePathStyle = true // Required for MinIO
})
// Verify the bucket exists, or create it
_, err = client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(cfg.Bucket)})
if err != nil {
client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(cfg.Bucket)})
}
return &Storage{client: client, bucket: cfg.Bucket, cfg: cfg}, nil
}
func (s *Storage) Upload(ctx context.Context, key string, reader io.Reader, contentType string) error // PutObject
func (s *Storage) Download(ctx context.Context, key string) (io.ReadCloser, error) // GetObject
func (s *Storage) Delete(ctx context.Context, key string) error // DeleteObject
func (s *Storage) GetURL(key string) string // build public URLWhat does it do? Gives us cloud file storage through the AWS S3 SDK — which also speaks MinIO (local), Cloudflare R2, and Backblaze B2. That means the same code works locally on MinIO in development and on real S3 in production — just a different .env.
Why UsePathStyle? MinIO uses path-style addressing (endpoint/bucket/key). That flag is the single line that makes everything compatible.
image processing (storage/image.go)
const MaxImageWidth = 1920
const ThumbnailSize = 300
// ProcessImage resizes images wider than 1920px, preserving aspect ratio.
func ProcessImage(reader io.Reader, mimeType string) ([]byte, error)
// GenerateThumbnail creates a square 300x300 thumbnail, center-cropped.
func GenerateThumbnail(reader io.Reader, mimeType string) ([]byte, error)What does it do? Uses the imaging library to resize huge uploads and make square thumbnails.
Why? An 8-megapixel photo shouldn't be served to every visitor — resize to 1920px max for the main image, and a tiny 300px square for cards. Faster site, less bandwidth.
the upload handler + jobs (controllers/upload_controller.go, jobs/jobs.go)
type UploadHandler struct {
DB *gorm.DB
Storage *storage.Storage
Jobs *jobs.Client
}
// jobs.Client processes images in the BACKGROUND so the request
// doesn't wait for thumbnail generation.
func (c *Client) EnqueueProcessImage(uploadID uint, key, mimeType string) error {
c.wg.Add(1)
go func() {
defer c.wg.Done()
c.processImage(uploadID, key, mimeType)
}()
return nil
}How they connect: the upload controller stores the file, saves the metadata, and then hands the thumbnail work to a goroutine via EnqueueProcessImage. Background job: download → make thumbnail → upload to thumbnails/ → update thumbnail_url — all without making the user's upload wait.
What does it help with? Your HTTP response returns fast even while big image work continues in the background. The frontend keeps polling the upload's thumbnail_url until it appears.
cache (cache/cache.go) — Redis wrapped up nice
type Cache struct {
client *redis.Client
}
func (c *Cache) Get(ctx context.Context, key string, dest interface{}) (bool, error) // false if key missing
func (c *Cache) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error
func (c *Cache) Delete(ctx context.Context, key string) error
func (c *Cache) DeletePattern(ctx context.Context, pattern string) error // e.g. "contact:*"
func (c *Cache) Flush(ctx context.Context) errorWhat does it do? JSON-marshals values into Redis and back, hiding the details. Get returns false on a miss — a clean Go idiom: "the value wasn't there."
audit (audit/audit.go) — a tamper-resistant history
type Logger struct {
db *gorm.DB
}
func (l *Logger) Log(ctx context.Context, e Entry) error {
// writes exactly one row to the audit_logs table
// rows are only ever CREATED — never updated or deleted
}What does it do? Records every important action (signup, login, password reset, message reply, admin changes) with WHO did it, from WHERE (IP), and WHAT changed. The rows are append-only by design — that's the whole point: the audit trail can't quietly change.
Why? If something goes wrong, you can see the exact sequence of events. And because only admins can read the audit logs (that route is behind RequireAuthWithToken + RequireAdmin()), it's also a security record.
common errors and fixes
Here are the exact errors I hit while building this — and the fixes:
1. SECRET_KEY is required at startup
Your .env is missing the secret, or the file isn't in the right place. The config package reads .env from the current working directory — run the app from the project root.
2. Failed to connect to the DB: ... connection refused
Docker isn't running, or the port is wrong. Check docker compose --profile infra up -d, then confirm the port matches between docker-compose.yml (5436) and .env (POSTGRES_PORT=5436).
3. Redis connection refused / ERR AUTH <password> called without any password configured for the default user
Either the Redis container isn't up (start infra profile) or your REDIS_URL password doesn't match the REDIS_PASSWORD in .env that Docker uses. Remember Redis here needs a password (--requirepass).
4. Every contact message returns 429 "Too many requests"
You've hit the rate limit from testing. Delete the key: redis-cli -a <password> DEL "contact:ratelimit:<your-ip>" or just docker compose restart redis. (The window is 1 hour — a deliberate choice for a public form.)
5. "can't load package: package ... is not in std" / import not found
You forgot go get for that dependency, or you imported with the wrong module path. Your imports must all start with your go.mod module path, e.g. github.com/yourname/kooza-collinz-portfolio-backend/models — not a "myapp/apps/api" placeholder. This exact bug bit me, hence the comment.
6. After a signup, no verification email appears
If you're using Docker, check Mailhog at http://localhost:8026 instead of a real inbox. In development, emails never go out for real — they land in Mailhog. For a real inbox, put a real RESEND_API_KEY in .env.
7. 400 Bad Request on signup/login
The DTO binding tags are rejecting your payload. Common causes: password shorter than 6 chars, wrong JSON field name (must be snake_case: first_name, not firstName), or a missing field.
8. 401 Unauthorized on /users/validate even with a token
Check the Authorization header format: Bearer <token> — no extra space, no quotes. Also make sure the token wasn't expired (the exp check).
9. CORS errors in the browser console
CORSMiddleware must be attached. And AllowCredentials: false must stay false while AllowAllOrigins: true — browsers reject the combination AllowAllOrigins + credentials.
10. "unexpected signing method"
Someone (or some code) tried to verify a token signed with a different algorithm. Our middleware only accepts HMAC (HS256). This check exists to prevent a known JWT attack where an attacker switches the algorithm to "none" — the type assertion token.Method.(*jwt.SigningMethodHMAC) is the shield.
11. OAuth callback lands on error=invalid_state
This happens when the user's browser makes an extra request (e.g. a favicon request hitting /google/callback without the state param) or the flow took longer than 5 minutes. Just try the login again — the state intentionally expires.
12. On Windows go install ...air@latest says "command not found"
Air installs to $HOME\go\bin (or your GOPATH/bin). Add that folder to your PATH, or run it via %USERPROFILE%\go\bin\air.exe. Also — you may need to open a new terminal after installing.
day-to-day commands
The commands you'll actually type, every single day:
# Start all infrastructure (database + storage + mailhog)
docker compose --profile infra --profile storage up -d
# Start the API with hot reload
air
# Run database migrations after changing models
go run ./migrations
# Just run once, no reload
go run .
# Stop everything
docker compose down
# Look at logs
docker compose logs -f
# Check for compile errors / bugs
go build ./...
go vet ./...Where to look in the browser while developing:
| Service | URL |
|---|---|
| API | http://localhost:8080 |
| GORM Studio | http://localhost:8080/studio |
| Mailhog | http://localhost:8026 |
| MinIO Console | http://localhost:9003 |
final checklist
Before you call this done, check every box. If you can tick them all, congratulations — you have a real, working backend.
-
go run ./migrationscompletes with "Database migration completed successfully!" -
docker compose psshows postgres, redis, and mailhog running -
airstarts the server on port 8080 without errors - POST
/api/v1/usersreturns201with a message about the verification code - The verification email shows up in Mailhog (http://localhost:8026)
- POST
/api/v1/users/loginreturns a realtoken - GET
/api/v1/users/validatewith that token returns your user - Warm-up test on rate limiting: 5 submissions are fine, the 6th gets
429 - GET
/api/v1/contactwithout a token returns401 - GET
/api/v1/contactwith a non-admin token returns403 - You can see the
users,contact_messages, andaudit_logstables in GORM Studio -
.envand*.exe/tmp/are all in.gitignore(no secrets committed) -
go build ./...andgo vet ./...pass clean
[!NOTE] The missing item on purpose: converting a user to
ADMIN. There's no public endpoint for that (for obvious reasons) — set it directly in your database via GORM Studio, or a one-off script.
conclusion
You just built a production-shaped backend — not a toy. Let's recap the journey:
- We bootstrapped a Go module and created a clean package layout.
- We stored secrets in
.envand read them through oneconfigpackage. - We connected PostgreSQL, Redis, and Resend through
initializers. - We described our data with GORM models and shaped our API with DTOs.
- We gated protected routes with JWT + role middleware.
- We built signup, login, email verification, and password reset with proper hashing and single-use tokens.
- We added OAuth — all five providers — through two reusable handlers.
- We rate-limited the contact form and cached its admin list in Redis.
- We created tables with a migration tool, and looked at them with GORM Studio.
- We made the server hot-reload with Air.
- We added file uploads to S3-compatible storage, with background thumbnail jobs and an audit trail.
Every piece of that came from small, explainable steps. And every step answered what, why, and what it helps — because that's how knowledge actually sticks.
The skills here — middleware chaining, hashing secrets, cache invalidation, rate limiting, the JWT verify flow, the OAuth redirect dance — transfer directly to any backend you'll ever write in Go. Take the project, rename it, put your own endpoints on it, and make it yours.
Now go build something. 🚀
resources
- JWT Authentication in Golang — the token flow, sign-in, and signature explained
- Go API Docs — a broader guide to structuring Go APIs
- Gin documentation — every route, group, and context method we used
- GORM documentation — models, queries, and AutoMigrate
- go-redis v9 — the Redis client we used for caching and rate limiting
- golang-jwt/jwt v5 — the token library
- Air (hot reload) — the file-watcher we run
- gorm-studio — the free database GUI we mounted at
/studio - My repository — the full source this guide is built from