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

FeatureImplementation
LanguageGo 1.25
Web FrameworkChi Router v5
DatabasePostgreSQL 15 (pgx)
CacheRedis 7
ContainerizationMulti-stage Docker builds
DeploymentAWS ECS Fargate
ObservabilityPrometheus + Zap logger + OpenTelemetry
SecurityJWT, 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

DecisionRationale
Hybrid token storageSHA-256 lookup + bcrypt verification prevents token replay attacks even on database breach
Immediate session revocationLogout invalidates tokens right away rather than waiting for natural expiry
OAuth2 provider abstractionFactory pattern makes adding new providers (Apple, LinkedIn) a matter of implementing one interface
Automatic token cleanupA cron job removes expired tokens, keeping the database lean and reducing attack surface
FindOrCreateUser logicA 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.


See the full code on GitHub