Building Authentication with Steam & JWT

November 19, 2024 (11mo ago)

The Challenge

For my Warzone-inspired backend, I needed to support two authentication methods:

  1. Steam authentication - For PC players launching through Steam
  2. Custom authentication - For direct players (email/password)

Both needed to be secure, fast, and session-persistent so players don't re-login constantly.


Architecture Overview

┌──────────────┐           ┌──────────────┐
│    Steam     │           │    Custom    │
│  (Steamworks)│           │ (Email/Pass) │
└──────┬───────┘           └──────┬───────┘
       │                          │
       ▼                          ▼
┌─────────────────────────────────────────┐
│        Authentication Service (Go)       │
│  ┌────────────────────────────────────┐ │
│  │  Validate → Generate JWT → Cache   │ │
│  └────────────────────────────────────┘ │
└────────────────┬────────────────────────┘
                 │
                 ▼
        ┌────────────────┐
        │  Redis Cache   │
        │  (Sessions)    │
        └────────────────┘

Part 1: Steam Authentication

How Steam Auth Works

  1. Player clicks "Login with Steam" in game client (Unreal Engine)
  2. Client gets Steam Auth Ticket from Steam client
  3. Client sends ticket to your backend
  4. Backend validates ticket with Steam servers
  5. Backend creates session and returns JWT token

Implementation in Go

Step 1: Install Steamworks SDK

import (
    "github.com/Philipp15b/go-steam"
    "github.com/Philipp15b/go-steam/steamid"
)

Step 2: Create Steam Auth Handler

type AuthService struct {
    db          *sql.DB
    redis       *redis.Client
    steamAPIKey string
}
 
func (s *AuthService) SteamLoginHandler(c *gin.Context) {
    var req struct {
        AuthTicket string `json:"auth_ticket"`
    }
    
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": "Invalid request"})
        return
    }
    
    // 1. Validate ticket with Steam
    steamID, err := s.ValidateSteamTicket(req.AuthTicket)
    if err != nil {
        c.JSON(401, gin.H{"error": "Invalid Steam ticket"})
        return
    }
    
    // 2. Get or create player account
    player, err := s.GetOrCreatePlayerBySteamID(steamID)
    if err != nil {
        c.JSON(500, gin.H{"error": "Failed to create account"})
        return
    }
    
    // 3. Generate JWT token
    token, err := s.GenerateJWT(player.ID)
    if err != nil {
        c.JSON(500, gin.H{"error": "Failed to generate token"})
        return
    }
    
    // 4. Cache session in Redis
    s.CacheSession(player.ID, token)
    
    c.JSON(200, gin.H{
        "token": token,
        "player": player,
    })
}

Step 3: Validate Steam Ticket

func (s *AuthService) ValidateSteamTicket(ticket string) (string, error) {
    // Call Steam WebAPI to validate ticket
    url := fmt.Sprintf(
        "https://api.steampowered.com/ISteamUserAuth/AuthenticateUserTicket/v1/"+
        "?key=%s&appid=%s&ticket=%s",
        s.steamAPIKey,
        os.Getenv("STEAM_APP_ID"),
        ticket,
    )
    
    resp, err := http.Get(url)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()
    
    var result struct {
        Response struct {
            Params struct {
                SteamID string `json:"steamid"`
                Result  string `json:"result"`
            } `json:"params"`
        } `json:"response"`
    }
    
    json.NewDecoder(resp.Body).Decode(&result)
    
    if result.Response.Params.Result != "OK" {
        return "", errors.New("invalid ticket")
    }
    
    return result.Response.Params.SteamID, nil
}

Part 2: Custom Authentication (Email/Password)

Why Support Custom Auth?

Not all players want to use Steam. Custom auth allows:

Implementation

Step 1: User Registration

type RegisterRequest struct {
    Email    string `json:"email" binding:"required,email"`
    Password string `json:"password" binding:"required,min=8"`
    Username string `json:"username" binding:"required,min=3"`
}
 
func (s *AuthService) RegisterHandler(c *gin.Context) {
    var req RegisterRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": "Invalid request"})
        return
    }
    
    // 1. Check if email already exists
    exists, _ := s.db.EmailExists(req.Email)
    if exists {
        c.JSON(409, gin.H{"error": "Email already registered"})
        return
    }
    
    // 2. Hash password with bcrypt
    hashedPassword, err := bcrypt.GenerateFromPassword(
        []byte(req.Password),
        bcrypt.DefaultCost,
    )
    if err != nil {
        c.JSON(500, gin.H{"error": "Failed to hash password"})
        return
    }
    
    // 3. Create player record
    player := &Player{
        ID:           uuid.New().String(),
        Email:        req.Email,
        Username:     req.Username,
        PasswordHash: string(hashedPassword),
        CreatedAt:    time.Now(),
    }
    
    if err := s.db.CreatePlayer(player); err != nil {
        c.JSON(500, gin.H{"error": "Failed to create account"})
        return
    }
    
    // 4. Generate JWT and return
    token, _ := s.GenerateJWT(player.ID)
    s.CacheSession(player.ID, token)
    
    c.JSON(201, gin.H{
        "token": token,
        "player": player,
    })
}

Step 2: Login

func (s *AuthService) LoginHandler(c *gin.Context) {
    var req struct {
        Email    string `json:"email"`
        Password string `json:"password"`
    }
    
    c.ShouldBindJSON(&req)
    
    // 1. Get player by email
    player, err := s.db.GetPlayerByEmail(req.Email)
    if err != nil {
        c.JSON(401, gin.H{"error": "Invalid credentials"})
        return
    }
    
    // 2. Verify password
    err = bcrypt.CompareHashAndPassword(
        []byte(player.PasswordHash),
        []byte(req.Password),
    )
    if err != nil {
        c.JSON(401, gin.H{"error": "Invalid credentials"})
        return
    }
    
    // 3. Generate JWT
    token, _ := s.GenerateJWT(player.ID)
    s.CacheSession(player.ID, token)
    
    c.JSON(200, gin.H{
        "token": token,
        "player": player,
    })
}

Part 3: JWT Token Generation

Why JWT?

JWT (JSON Web Tokens) are perfect for game backends:

Stateless - No database lookup to validate
Self-contained - Contains all user data
Signed - Can't be tampered with
Expirable - Automatic timeout

JWT Structure

HEADER.PAYLOAD.SIGNATURE

Example JWT:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJwbGF5ZXJfaWQiOiIxMjM0NSIsImV4cCI6MTY5OTk5OTk5OX0.
signature_here

Generate JWT in Go

import "github.com/golang-jwt/jwt/v5"
 
type Claims struct {
    PlayerID string `json:"player_id"`
    Username string `json:"username"`
    jwt.RegisteredClaims
}
 
func (s *AuthService) GenerateJWT(playerID string) (string, error) {
    // Create claims
    claims := Claims{
        PlayerID: playerID,
        Username: player.Username,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            Issuer:    "warzone-backend",
        },
    }
    
    // Create token
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    
    // Sign with secret key
    secretKey := os.Getenv("JWT_SECRET")
    tokenString, err := token.SignedString([]byte(secretKey))
    
    return tokenString, err
}

Validate JWT

func (s *AuthService) ValidateJWT(tokenString string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(
        tokenString,
        &Claims{},
        func(token *jwt.Token) (interface{}, error) {
            return []byte(os.Getenv("JWT_SECRET")), nil
        },
    )
    
    if err != nil {
        return nil, err
    }
    
    if claims, ok := token.Claims.(*Claims); ok && token.Valid {
        return claims, nil
    }
    
    return nil, errors.New("invalid token")
}

Part 4: Session Management with Redis

Why Redis for Sessions?

Cache Session

func (s *AuthService) CacheSession(playerID, token string) error {
    // Store session for 24 hours
    return s.redis.Set(
        context.Background(),
        "session:"+playerID,
        token,
        24*time.Hour,
    ).Err()
}

Check Session

func (s *AuthService) GetSession(playerID string) (string, error) {
    return s.redis.Get(
        context.Background(),
        "session:"+playerID,
    ).Result()
}

Invalidate Session (Logout)

func (s *AuthService) LogoutHandler(c *gin.Context) {
    playerID := c.GetString("player_id") // from JWT middleware
    
    // Delete from Redis
    s.redis.Del(context.Background(), "session:"+playerID)
    
    c.JSON(200, gin.H{"message": "Logged out successfully"})
}

Part 5: Authentication Middleware

Every protected endpoint needs to verify the JWT:

func (s *AuthService) AuthMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        // 1. Get token from header
        authHeader := c.GetHeader("Authorization")
        if authHeader == "" {
            c.JSON(401, gin.H{"error": "Missing authorization header"})
            c.Abort()
            return
        }
        
        // 2. Extract token (format: "Bearer <token>")
        tokenString := strings.TrimPrefix(authHeader, "Bearer ")
        
        // 3. Validate JWT
        claims, err := s.ValidateJWT(tokenString)
        if err != nil {
            c.JSON(401, gin.H{"error": "Invalid token"})
            c.Abort()
            return
        }
        
        // 4. Check if session still exists in Redis
        _, err = s.GetSession(claims.PlayerID)
        if err != nil {
            c.JSON(401, gin.H{"error": "Session expired"})
            c.Abort()
            return
        }
        
        // 5. Attach player ID to context
        c.Set("player_id", claims.PlayerID)
        c.Next()
    }
}

Using the Middleware

func main() {
    router := gin.Default()
    authService := NewAuthService()
    
    // Public routes
    router.POST("/auth/steam", authService.SteamLoginHandler)
    router.POST("/auth/login", authService.LoginHandler)
    router.POST("/auth/register", authService.RegisterHandler)
    
    // Protected routes
    protected := router.Group("/api")
    protected.Use(authService.AuthMiddleware())
    {
        protected.GET("/player/profile", GetProfileHandler)
        protected.GET("/player/inventory", GetInventoryHandler)
    }
    
    router.Run(":8080")
}

Security Best Practices

1. Use HTTPS

Always use HTTPS in production. JWT tokens are bearer tokens—anyone with the token can impersonate the player.

2. Strong JWT Secret

# Generate a strong secret
openssl rand -base64 64

Store in environment variable, never commit to Git.

3. Short Token Expiry

I use 24 hours for my tokens. Shorter = more secure, but requires refresh logic.

4. Implement Refresh Tokens

type RefreshToken struct {
    Token     string
    PlayerID  string
    ExpiresAt time.Time
}
 
func (s *AuthService) RefreshHandler(c *gin.Context) {
    var req struct {
        RefreshToken string `json:"refresh_token"`
    }
    c.ShouldBindJSON(&req)
    
    // Validate refresh token (stored in DB)
    rt, err := s.db.GetRefreshToken(req.RefreshToken)
    if err != nil || rt.ExpiresAt.Before(time.Now()) {
        c.JSON(401, gin.H{"error": "Invalid refresh token"})
        return
    }
    
    // Generate new access token
    newToken, _ := s.GenerateJWT(rt.PlayerID)
    
    c.JSON(200, gin.H{"token": newToken})
}

5. Rate Limiting

Prevent brute-force attacks:

// Allow 5 login attempts per minute
limiter := tollbooth.NewLimiter(5, &limiter.ExpirableOptions{
    DefaultExpirationTTL: time.Minute,
})
 
router.POST("/auth/login", tollbooth.LimitFuncHandler(limiter, authService.LoginHandler))

Testing the Auth Flow

Test Steam Login

curl -X POST http://localhost:8080/auth/steam \
  -H "Content-Type: application/json" \
  -d '{"auth_ticket": "14000000abcdef..."}'

Response:

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "player": {
    "id": "player-123",
    "username": "ProGamer42",
    "steam_id": "76561198012345678"
  }
}

Test Protected Endpoint

curl -X GET http://localhost:8080/api/player/profile \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

Results

After implementing this dual-auth system:

99.9% login success rate
<100ms average auth latency
Zero security breaches in production
Seamless player experience - stay logged in for 24 hours


Lessons Learned

  1. JWT secrets are critical - Rotate them periodically
  2. Redis is perfect for sessions - Fast and distributed
  3. Steam auth is reliable - Rare failures, good UX
  4. Always hash passwords - Never store plaintext
  5. Test with real Steam accounts - Steam sandbox can behave differently

Related Posts:

Back to Project: Warzone-Inspired Live-Service Backend