Authentication Patterns: JWT, Sessions, OAuth - What Actually Works
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:
Session-based: Server remembers you
Token-based (JWT): Client carries proof
OAuth/OIDC: Let someone else handle it
Session-Based Authentication
How It Works
User logs in with username/password
Server creates a session, stores it (memory/Redis/DB)
Server sends session ID to client as cookie
Client sends cookie with every request
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
User logs in
Server creates JWT containing user info
Server signs JWT with secret key
Client stores JWT (usually in memory or cookie)
Client sends JWT with every request
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-Only Cookie (Best Option)
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)
User clicks "Login with Google"
Redirect to Google's login page
User logs in at Google
Google redirects back with authorization code
Your server exchanges code for access token
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!