March 14, 2026
Building a Production-Grade Golang Auth Service
A complete auth microservice in Go: JWT access/refresh with hybrid token storage, Google/GitHub OAuth2, bcrypt passwords, password reset email flows, Redis workers, ECS Fargate, Prometheus, and OpenTelemetry.
Authentication is one of the most critical components of any modern application—and one of the most misunderstood. Most tutorials teach you how to wire up a basic login form. Real production systems need refresh token rotation, OAuth2 integration, secure session management, and password reset flows—all while maintaining security, performance, and developer experience.
This post walks through how I built a complete authentication microservice featuring JWT access/refresh tokens, Google/GitHub OAuth2, bcrypt password hashing, password reset via email templates, and automatic token cleanup—deployed to AWS ECS Fargate with Prometheus monitoring and OpenTelemetry tracing.
Repository: github.com/kaungmyathan22/golang-auth-service
Quick navigation
- Project Overview
- Architecture
- Core Components
- Key Design Decisions
- Observability
- Deployment
- Lessons Learned
Project Overview
| Feature | Implementation |
|---|---|
| Language | Go 1.25 |
| Web Framework | Chi Router v5 |
| Database | PostgreSQL 15 (pgx) |
| Cache | Redis 7 |
| Containerization | Multi-stage Docker builds |
| Deployment | AWS ECS Fargate |
| Observability | Prometheus + Zap logger + OpenTelemetry |
| Security | JWT, OAuth2, bcrypt, SHA-256 + bcrypt hybrid tokens |
Architecture
┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ HTTP Client │ → │ REST API │ → │ PostgreSQL DB │
│ (Browser) │ │ /api/v1/auth │ │ Users Table │
└───────────────┘ └─────────────────┘ └─────────────────┘
↓ ↓ ↓
Login/Register JWT + OAuth2 Refresh Tokens
Password Reset Token Rotation Session Mgmt
Core Components
1. Access + Refresh Token System
JWT access tokens are short-lived (24 hours). Refresh tokens enable seamless re-authentication without requiring users to log in again each session.
Critical Security Decision: Rather than storing plaintext refresh tokens, I implemented a hybrid approach:
- Store a SHA-256 hash of the plaintext token for fast lookup (opaque, non-reversible)
- Verify the actual token value using bcrypt comparison
- Mark tokens as used/revoked on rotation or logout
// File: internal/service/token_refresh.go
func StoreRefreshToken(ctx context.Context, db *database.DB, userID, tokenValue, deviceInfo string) (*RefreshToken, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(tokenValue), bcrypt.DefaultCost)
lookup := refreshTokenLookup(tokenValue) // SHA-256
_, err = db.Pool.Exec(ctx,
`INSERT INTO refresh_tokens
(id, user_id, token, token_lookup, created_at, expires_at)
VALUES ($1,$2,$3,$4,$5,$6)`,
rt.ID, rt.UserID, hashed, lookup, ...
)
}
This protects against token leakage—if the database is compromised, attackers cannot reverse the stored values back to usable tokens.
2. OAuth2 Integration (Google + GitHub)
Social login improves conversion and reduces friction. I implemented a provider interface that abstracts away each platform's quirks:
// File: internal/oauth2/google_provider.go
type GoogleProvider struct {
ClientID string
ClientSecret string
RedirectURL string
}
func (p *GoogleProvider) GetUser(ctx context.Context, token *oauth2.Token) (*User, error) {
httpClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token))
resp, err := httpClient.Get("https://www.googleapis.com/oauth2/v2/userinfo")
// Parse and return User object
}
Google and GitHub expose different APIs and return different user shapes. The provider interface normalizes both into a single User type, making it trivial to add new providers later.
3. Password Reset Flow
Reset tokens expire after 1 hour and are invalidated immediately upon use. Email notifications are sent via SMTP using branded HTML templates.
-- migrations/005_create_password_reset_tokens.sql
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL UNIQUE, -- SHA-256 hash of plaintext token
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ
);
<!DOCTYPE html>
<html>
<head><title>Password Reset</title></head>
<body>
<div style="max-width: 600px; margin: 0 auto;">
<h2>Reset Your Password</h2>
<a href="{{ .ResetURL }}">Click here to proceed</a>
<p>This link will expire in 1 hour.</p>
</div>
</body>
</html>
Tokens are stored as SHA-256 hashes—never plaintext—following the same principle as the refresh token system.
4. Worker Pool Pattern for Background Tasks
Long-running tasks like email delivery and avatar processing should never block an HTTP response. Workers pull tasks from a Redis queue asynchronously:
// File: internal/worker/worker.go
func (p *Pool) loop(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
return
default:
msg, err := p.queue.Dequeue(ctx, "tasks", 2*time.Second)
if err != nil { continue }
_ = p.svc.HandleQueuePayload(ctx, msg)
}
}
}
This pattern keeps API response times fast and decouples side effects from the request lifecycle.
Key Design Decisions
| Decision | Rationale |
|---|---|
| Hybrid token storage | SHA-256 lookup + bcrypt verification prevents token replay attacks even on database breach |
| Immediate session revocation | Logout invalidates tokens right away rather than waiting for natural expiry |
| OAuth2 provider abstraction | Factory pattern makes adding new providers (Apple, LinkedIn) a matter of implementing one interface |
| Automatic token cleanup | A cron job removes expired tokens, keeping the database lean and reducing attack surface |
| FindOrCreateUser logic | A single function handles both new registrations and returning OAuth2 users cleanly |
Observability
The service is instrumented with three layers of observability:
- Prometheus — Exposes HTTP metrics (latency, error rates, request counts) for alerting and dashboards
- Zap logger — Structured JSON logging at every request boundary and error site
- OpenTelemetry — Distributed tracing across service boundaries for debugging slow requests
Deployment
The service runs on AWS ECS Fargate using multi-stage Docker builds to keep the final image small and free of build tooling. The multi-stage approach separates the Go compiler environment from the runtime image, resulting in a lean production container.
Lessons Learned
Security is in the defaults. Storing tokens as plaintext is the easy path—but a single database leak becomes a catastrophic breach. Hybrid hashing takes a few extra lines and pays for itself the moment something goes wrong.
Abstraction at the right layer matters. OAuth2 providers are superficially similar but diverge in the details. Building the abstraction around the normalized User output—not the raw HTTP calls—meant adding GitHub after Google took under an hour.
Background workers are worth the complexity. Synchronous email sending works until your SMTP provider has a slow moment. Moving it to a queue decoupled reliability from user-facing latency immediately.
Consistency compounds. The hardest part of any side project is showing up after a long day. Small, daily progress beats sporadic bursts—the codebase reflects that rhythm.