Skip to main content

Command Palette

Search for a command to run...

Authentication Patterns: JWT, Sessions, OAuth - What Actually Works

Published
9 min read
E

Backend Developer | Golang & Python I enjoy building reliable APIs, distributed systems, and automation tools. Writing here about backend engineering, system design, and real-world dev experiences.

Introduction

I've implemented authentication wrong more times than I'd like to admit.

JWT tokens stored in localStorage (rookie mistake). Session cookies without proper security flags. OAuth flows that leaked tokens. Password resets that were exploitable.

Every mistake taught me something. After building auth systems for years, I finally understand what actually works in production.

In this post, I'll show you different authentication patterns, when to use each, and the security pitfalls that will bite you if you're not careful.

The Fundamentals: What is Authentication?

Authentication: Proving you are who you say you are. Authorization: Determining what you're allowed to do.

Don't confuse them. This post focuses on authentication.

Three main approaches:

  1. Session-based: Server remembers you

  2. Token-based (JWT): Client carries proof

  3. OAuth/OIDC: Let someone else handle it

Session-Based Authentication

How It Works

  1. User logs in with username/password

  2. Server creates a session, stores it (memory/Redis/DB)

  3. Server sends session ID to client as cookie

  4. Client sends cookie with every request

  5. Server looks up session to verify user

Implementation in Go

type Session struct {
    UserID    string
    ExpiresAt time.Time
}

var sessions = make(map[string]*Session) // In production: use Redis

func Login(w http.ResponseWriter, r *http.Request) {
    var creds Credentials
    json.NewDecoder(r.Body).Decode(&creds)

    user, err := authenticateUser(creds.Username, creds.Password)
    if err != nil {
        http.Error(w, "Invalid credentials", http.StatusUnauthorized)
        return
    }

    // Create session
    sessionID := generateSecureID()
    sessions[sessionID] = &Session{
        UserID:    user.ID,
        ExpiresAt: time.Now().Add(24 * time.Hour),
    }

    // Set cookie
    http.SetCookie(w, &http.Cookie{
        Name:     "session_id",
        Value:    sessionID,
        HttpOnly: true,      // Prevents JavaScript access
        Secure:   true,      // HTTPS only
        SameSite: http.SameSiteStrictMode,
        MaxAge:   86400,     // 24 hours
        Path:     "/",
    })

    json.NewEncoder(w).Encode(user)
}

func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        cookie, err := r.Cookie("session_id")
        if err != nil {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }

        session, exists := sessions[cookie.Value]
        if !exists || session.ExpiresAt.Before(time.Now()) {
            http.Error(w, "Session expired", http.StatusUnauthorized)
            return
        }

        // Add user to context
        ctx := context.WithValue(r.Context(), "user_id", session.UserID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Pros of Sessions

✓ Server has full control (can invalidate immediately) ✓ No sensitive data on client ✓ Simple to implement ✓ Works well with server-side rendering ✓ Can store complex session data

Cons of Sessions

✗ Requires server-side storage (Redis, DB) ✗ Sticky sessions needed for load balancing ✗ Harder to scale horizontally ✗ Not ideal for microservices ✗ CSRF attacks are a concern

When to Use Sessions

  • Traditional web applications with server-side rendering

  • When you need instant session invalidation

  • Single-server or simple architecture

  • High security requirements (server controls everything)

JWT (JSON Web Tokens)

How It Works

  1. User logs in

  2. Server creates JWT containing user info

  3. Server signs JWT with secret key

  4. Client stores JWT (usually in memory or cookie)

  5. Client sends JWT with every request

  6. Server verifies signature and extracts user info

No server-side session storage needed.

JWT Structure

header.payload.signature

// Header
{
  "alg": "HS256",
  "typ": "JWT"
}

// Payload
{
  "user_id": "123",
  "email": "user@example.com",
  "exp": 1735689600
}

// Signature
HMACSHA256(base64(header) + "." + base64(payload), secret)

Implementation in Go

import "github.com/golang-jwt/jwt/v5"

type Claims struct {
    UserID string `json:"user_id"`
    Email  string `json:"email"`
    jwt.RegisteredClaims
}

var jwtSecret = []byte("your-secret-key") // In production: env variable

func GenerateJWT(userID, email string) (string, error) {
    claims := &Claims{
        UserID: userID,
        Email:  email,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            Issuer:    "my-app",
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(jwtSecret)
}

func Login(w http.ResponseWriter, r *http.Request) {
    var creds Credentials
    json.NewDecoder(r.Body).Decode(&creds)

    user, err := authenticateUser(creds.Username, creds.Password)
    if err != nil {
        http.Error(w, "Invalid credentials", http.StatusUnauthorized)
        return
    }

    token, err := GenerateJWT(user.ID, user.Email)
    if err != nil {
        http.Error(w, "Failed to generate token", http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(map[string]string{
        "token": token,
    })
}

func JWTMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        authHeader := r.Header.Get("Authorization")
        if authHeader == "" {
            http.Error(w, "Missing token", http.StatusUnauthorized)
            return
        }

        tokenString := strings.Replace(authHeader, "Bearer ", "", 1)

        claims := &Claims{}
        token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
            return jwtSecret, nil
        })

        if err != nil || !token.Valid {
            http.Error(w, "Invalid token", http.StatusUnauthorized)
            return
        }

        ctx := context.WithValue(r.Context(), "user_id", claims.UserID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Pros of JWT

✓ Stateless (no server-side storage) ✓ Scales horizontally easily ✓ Works great for microservices ✓ Can be used across domains ✓ Self-contained (payload has user info)

Cons of JWT

✗ Can't invalidate before expiration ✗ Larger than session IDs (bandwidth) ✗ Token compromise = valid until expiration ✗ Sensitive data in token (it's just base64) ✗ No built-in refresh mechanism

When to Use JWT

  • Microservices architecture

  • Mobile apps / SPAs

  • APIs consumed by third parties

  • Distributed systems

  • When you need stateless authentication

The JWT Storage Problem

Critical mistake: Where you store JWT on the client.

❌ LocalStorage (Don't Do This)

// BAD - Vulnerable to XSS
localStorage.setItem('token', jwt);

Why it's bad: XSS attacks can steal it.

❌ SessionStorage (Also Bad)

// Still BAD
sessionStorage.setItem('token', jwt);

Same XSS vulnerability.

http.SetCookie(w, &http.Cookie{
    Name:     "auth_token",
    Value:    token,
    HttpOnly: true,  // JavaScript can't access
    Secure:   true,  // HTTPS only
    SameSite: http.SameSiteStrictMode,
    MaxAge:   86400,
})

JavaScript can't access it. XSS attacks can't steal it.

✓ Memory (for SPAs)

Store in JavaScript variable. Lost on page refresh (that's a feature).

let authToken = null;  // In memory

Requires refresh token mechanism.

Refresh Tokens Pattern

Problem: JWTs expire. How do you stay logged in?

Solution: Access token + Refresh token

Access Token: Short-lived (15 minutes)
Refresh Token: Long-lived (7 days), stored securely

Implementation

type TokenPair struct {
    AccessToken  string `json:"access_token"`
    RefreshToken string `json:"refresh_token"`
}

func Login(w http.ResponseWriter, r *http.Request) {
    user, err := authenticateUser(username, password)
    if err != nil {
        http.Error(w, "Invalid credentials", http.StatusUnauthorized)
        return
    }

    // Short-lived access token
    accessToken, _ := GenerateJWT(user.ID, user.Email, 15*time.Minute)

    // Long-lived refresh token
    refreshToken := generateSecureToken()
    storeRefreshToken(user.ID, refreshToken, 7*24*time.Hour) // Store in DB

    json.NewEncoder(w).Encode(TokenPair{
        AccessToken:  accessToken,
        RefreshToken: refreshToken,
    })
}

func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
    var req struct {
        RefreshToken string `json:"refresh_token"`
    }
    json.NewDecoder(r.Body).Decode(&req)

    userID, valid := validateRefreshToken(req.RefreshToken)
    if !valid {
        http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
        return
    }

    // Generate new access token
    accessToken, _ := GenerateJWT(userID, "", 15*time.Minute)

    json.NewEncoder(w).Encode(map[string]string{
        "access_token": accessToken,
    })
}

Why This Works

  • Access token expires quickly (limits damage if stolen)

  • Refresh token stored securely server-side (can be revoked)

  • Client automatically refreshes access token

  • Logout revokes refresh token

OAuth 2.0 / OpenID Connect

"Let Google/GitHub handle authentication for you."

OAuth Flow (Simplified)

  1. User clicks "Login with Google"

  2. Redirect to Google's login page

  3. User logs in at Google

  4. Google redirects back with authorization code

  5. Your server exchanges code for access token

  6. Use token to get user info from Google

Implementation in Go

import "golang.org/x/oauth2"
import "golang.org/x/oauth2/google"

var googleOAuthConfig = &oauth2.Config{
    ClientID:     "your-client-id",
    ClientSecret: "your-client-secret",
    RedirectURL:  "http://localhost:8080/auth/callback",
    Scopes:       []string{"email", "profile"},
    Endpoint:     google.Endpoint,
}

func GoogleLoginHandler(w http.ResponseWriter, r *http.Request) {
    state := generateRandomState()
    // Store state in session to prevent CSRF

    url := googleOAuthConfig.AuthCodeURL(state)
    http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}

func GoogleCallbackHandler(w http.ResponseWriter, r *http.Request) {
    code := r.URL.Query().Get("code")
    state := r.URL.Query().Get("state")

    // Verify state to prevent CSRF
    if !verifyState(state) {
        http.Error(w, "Invalid state", http.StatusBadRequest)
        return
    }

    // Exchange code for token
    token, err := googleOAuthConfig.Exchange(context.Background(), code)
    if err != nil {
        http.Error(w, "Failed to exchange token", http.StatusInternalServerError)
        return
    }

    // Get user info
    client := googleOAuthConfig.Client(context.Background(), token)
    resp, _ := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
    defer resp.Body.Close()

    var userInfo struct {
        ID    string `json:"id"`
        Email string `json:"email"`
        Name  string `json:"name"`
    }
    json.NewDecoder(resp.Body).Decode(&userInfo)

    // Create or find user in your database
    user, _ := findOrCreateUser(userInfo)

    // Create your own session/JWT for the user
    sessionToken, _ := GenerateJWT(user.ID, user.Email, 24*time.Hour)

    http.SetCookie(w, &http.Cookie{
        Name:     "session",
        Value:    sessionToken,
        HttpOnly: true,
        Secure:   true,
        MaxAge:   86400,
    })

    http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}

When to Use OAuth

✓ Don't want to handle passwords ✓ Want social login (Google, GitHub, Facebook) ✓ B2B apps (company SSO) ✓ Reduce security risk (outsource to experts)

Security Best Practices

1. Password Storage (Never Store Plain Text!)

import "golang.org/x/crypto/bcrypt"

func HashPassword(password string) (string, error) {
    bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
    return string(bytes), err
}

func CheckPassword(password, hash string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
    return err == nil
}

Never:

  • Store passwords in plain text

  • Use MD5 or SHA1 for passwords

  • Use weak hashing algorithms

Always:

  • Use bcrypt, argon2, or scrypt

  • Use high cost factor (at least 12 for bcrypt)

2. HTTPS Only

// Redirect HTTP to HTTPS
if r.TLS == nil {
    http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusMovedPermanently)
    return
}

Never send credentials over HTTP. Ever.

3. Rate Limiting

import "golang.org/x/time/rate"

var loginLimiter = rate.NewLimiter(5, 10) // 5 requests/second, burst 10

func LoginWithRateLimit(w http.ResponseWriter, r *http.Request) {
    if !loginLimiter.Allow() {
        http.Error(w, "Too many requests", http.StatusTooManyRequests)
        return
    }

    // Normal login logic
}

Prevents brute force attacks.

4. CSRF Protection

// Generate CSRF token
func GenerateCSRFToken() string {
    return generateSecureToken()
}

// Verify CSRF token
func CSRFMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE" {
            token := r.Header.Get("X-CSRF-Token")
            if !verifyCSRFToken(token) {
                http.Error(w, "Invalid CSRF token", http.StatusForbidden)
                return
            }
        }
        next.ServeHTTP(w, r)
    })
}

5. Multi-Factor Authentication (2FA)

import "github.com/pquerna/otp/totp"

func Enable2FA(userID string) (string, error) {
    key, err := totp.Generate(totp.GenerateOpts{
        Issuer:      "MyApp",
        AccountName: userID,
    })
    if err != nil {
        return "", err
    }

    // Store key.Secret() for user
    saveUserSecret(userID, key.Secret())

    // Return QR code URL for user to scan
    return key.URL(), nil
}

func Verify2FACode(userID, code string) bool {
    secret := getUserSecret(userID)
    return totp.Validate(code, secret)
}

Common Mistakes

Mistake 1: JWT in localStorage with XSS Vulnerability

Bad:

localStorage.setItem('token', jwt);

Any XSS attack steals the token.

Good: Use HTTP-only cookies or store in memory.

Mistake 2: No Token Expiration

Bad:

token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// No expiration!

Token valid forever. If stolen, attacker has permanent access.

Good: Always set expiration:

claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(15 * time.Minute))

Mistake 3: Weak Secret Keys

Bad:

var jwtSecret = []byte("secret")

Good:

var jwtSecret = []byte(os.Getenv("JWT_SECRET")) // 32+ random bytes

Mistake 4: No HTTPS

Sending auth tokens over HTTP = handing them to attackers.

Always use HTTPS in production.

Mistake 5: Storing Sensitive Data in JWT

Bad:

claims := Claims{
    CreditCard: "1234-5678-9012-3456", // NO!
    SSN:        "123-45-6789",         // NO!
}

JWT is base64-encoded, not encrypted. Anyone can decode it.

Good: Only store non-sensitive identifiers:

claims := Claims{
    UserID: "123",
    Role:   "admin",
}

My Recommendation

Here's what I actually use in production:

For traditional web apps:

  • Session-based auth with Redis

  • HTTP-only cookies

  • CSRF protection

For SPAs / mobile apps:

  • JWT access tokens (short-lived, 15 min)

  • Refresh tokens (stored in DB, 7 days)

  • HTTP-only cookies for tokens

  • 2FA for sensitive operations

For public APIs:

  • API keys for server-to-server

  • OAuth 2.0 for user-facing

  • Rate limiting per key

For internal microservices:

  • JWT with service accounts

  • Mutual TLS for service-to-service

  • Short expiration times

Conclusion

Authentication is hard. Every approach has trade-offs:

  • Sessions: Simple, secure, harder to scale

  • JWT: Scalable, stateless, harder to revoke

  • OAuth: Outsourced, easy, dependency on third party

Choose based on your requirements, not hype.

And remember: Security isn't optional. HTTPS, password hashing, rate limiting—these aren't nice-to-haves.

Questions? Drop a comment!