Gradient background
Backend20 min read

Auth Beyond Tokens Implementing Session Lifecycle

Session-Based Authentication in Go Banner

Hands up! Who are you? πŸ”«

Wait… don't just shout your name. How does a website know it's really you? How can it remember you're logged in across multiple requests without asking for your password every single time? How can it prevent account sharing and keep your data secure?

Welcome to session-based authentication β€” the way servers track who you are and remember you across requests without repeatedly asking for your credentials.

Before You Start: Why Sessions?

When you visit a website, your browser makes separate HTTP requests for each action. HTTP is stateless β€” it doesn't remember who you are by default. So every time you click a button, the server has no idea if you're the same person who just logged in.

You have two main choices to solve this:

Option 1: Sessions (Server-Side Memory)

The server keeps track of who's logged in. When you log in, the server creates a session and gives your browser a small token (usually in a cookie). On your next request, your browser sends this token back, and the server looks it up to remember you.

  • Best for: Traditional web apps, when you need instant control (logout immediately), per-device tracking, or handling sensitive data.
  • Tradeoff: The server must store session data, which uses memory/database space.

Option 2: Stateless Tokens (JWT)

The server doesn't store anything. Instead, it creates a signed token that contains your user information, and you send this token with every request. The server verifies the token's signature to ensure it's real.

  • Best for: Mobile apps, microservices, or distributed systems where you don't want a central storage point.
  • Tradeoff: Revoking a token is hard β€” the server can't just delete it. You have to wait for it to expire naturally.

Why We're Using Sessions in This Tutorial

We're building a traditional web application where the server controls everything. Sessions give us instant logout (crucial if someone's account is compromised), per-device tracking (know which devices a user is logged in from), and the ability to enforce rules like "max 5 active sessions per user." If your app needs these guarantees, sessions are the way to go.

Real-World Example

Imagine you're logging into your Gmail account. You enter your email and password. The server checks these are correct, then creates a session and puts a session ID in a cookie. Your browser automatically sends this cookie on every request to Gmail. Gmail's server checks the session ID against its records, finds you, and shows your emails. When you click logout, Gmail deletes the session immediately β€” you're instantly logged out everywhere. That's sessions in action.

Prerequisites:

  • Go 1.21+ installed on your machine
  • Redis running locally (or use Docker: docker run -d -p 6379:6379 redis) β€” Redis is a fast, in-memory data store we'll use to save session information
  • Basic understanding of HTTP and REST APIs
  • Familiarity with Go syntax

What We'll Build

By the end of this tutorial, we'll have a complete session-based auth system that can:

  • Log in users and manage their sessions
  • Store sessions in Redis with metadata (IP, user agent, timestamps)
  • Track and limit active sessions per user
  • Protect routes with middleware that validates sessions
  • Handle logout and automatic session cleanup

1️⃣ What is Session-Based Authentication?

Session-based authentication is a method where the server keeps track of user login state. Here's the flow:

  • When a user logs in, the server validates their credentials (email and password)
  • The server generates a unique session ID (SID) β€” essentially a random token
  • This SID is stored in a server-side store (in our case, Redis) along with user information
  • The client receives the SID in a cookie (a small file stored in the browser)
  • On subsequent requests, the browser automatically sends this cookie to the server
  • The server validates the SID for each request to confirm the user's identity

Sessions vs JWT: Feature Comparison

Feature
Sessions
JWT
Server Storage Required
Redis/Database needed
Stateless, no storage
Instant Logout
Delete session β†’ immediate effect
Must wait for token expiration
Per-Device Tracking
Track each device separately
No device-level control
Horizontal Scaling
Shared Redis/DB required
Works across distributed systems
Security Control
Granular control (IP, device, etc.)
Limited once issued
Performance
DB lookup on each request
Just signature verification
Session Limits
Enforce max sessions easily
Hard to enforce limits

Choose Sessions For:

  • Traditional web apps with server-side rendering
  • When you need instant logout capability
  • Per-device session management required
  • Banking, healthcare, or high-security apps
  • When enforcing session limits (e.g., max 5 devices)

Choose JWT For:

  • Mobile apps and SPAs (Single Page Apps)
  • Microservices architecture
  • APIs consumed by third-party clients
  • Distributed systems without shared storage
  • When server-side storage is expensive/complex

How It Works: Step-by-Step

  1. User submits email and password
  2. Server validates the credentials against the database
  3. Server generates a random session ID
  4. Server stores the session in Redis under a key like session:abc123
  5. Server also records this session ID in a set under user_sessions:42 (so we know all active sessions for user 42)
  6. Server sends the session ID to the browser as a cookie
  7. On the next request, the browser automatically sends the cookie
  8. Server reads the cookie, looks up the session in Redis, and confirms the user's identity

πŸ—„οΈ Redis Storage Structure

Redis is like a super-fast in-memory database. We use it to store sessions because they're temporary β€” they expire and get deleted. Here's what we store:

1. Session Data (session:<sid>)

Contains session metadata stored as a JSON string. This holds all information about a specific session:

session:1f8b9c2a7d4e -> {
  "sid": "1f8b9c2a7d4e",           // Session ID for reference          
  "userId": 42,                     // Who this session belongs to
  "ip": "192.168.1.10",             // User's IP address (useful for security)
  "userAgent": "Mozilla/5.0...",    // Browser/device info
  "createdAt": 1690000000,          // When session was created
  "lastAccessed": 1690000500        // Last time this session was used
}

2. User's Active Sessions (user_sessions:<userId>)

A Redis Set containing all active session IDs for a specific user. This lets us track multiple logins:

user_sessions:42 -> Set {
  "1f8b9c2a7d4e",    // Session on laptop
  "3e9d2b4c6f1a",    // Session on phone
  "7a4f8e1b2c3d"     // Session on tablet
}

Why This Structure?

  • Each session ID is a random unique string generated securely
  • session:<sid> keys let us quickly look up session details
  • user_sessions:<userId> lets us see all devices a user is logged in on
  • This setup makes it easy to enforce rules like "max 5 active sessions" by deleting the oldest ones

πŸ”’ Security: Protecting Sessions from Attacks

Sessions are valuable targets for attackers. If someone steals your session cookie, they can impersonate you. Here's how we protect against common attacks:

1. The HttpOnly Flag

The Problem: JavaScript running on a website (including malicious scripts from ads or hacked third-party code) can read cookies. If it can read your session cookie, it can send it to an attacker's server.

The Solution: Set HttpOnly: true on the cookie. This tells the browser: "Only send this cookie automatically with HTTP requests. JavaScript cannot access it."

2. The Secure Flag

The Problem: If someone is on the same WiFi network as you (like at a cafΓ©), they can intercept unencrypted traffic and steal your session cookie.

The Solution: Set Secure: true on the cookie. This tells the browser: "Only send this cookie over HTTPS (encrypted connections)."

3. The SameSite Flag

The Problem (CSRF β€” Cross-Site Request Forgery): Imagine you're logged into your bank's website. You then visit a malicious website. That site has hidden code that makes a request to your bank's website (like "transfer $1000"). Since your browser has your bank's session cookie, it sends it automatically, and the bank thinks you approved the transfer.

The Solution: Set SameSite: Strict . This tells the browser: "Only send this cookie if the request comes from the same website."

All Three Together

In our code, we set:

http.SetCookie(c.Writer, &http.Cookie{
  HttpOnly: true,              // JavaScript can't read it (prevents XSS theft)
  Secure: true,                // Only sent over HTTPS (prevents WiFi interception)
  SameSite: http.SameSiteStrictMode,  // Only sent to our site (prevents CSRF)
})

This creates a cookie that's highly resistant to theft. An attacker would need to either compromise HTTPS, trick you into visiting a malicious site, or directly access your browser's storage β€” all very difficult.

2️⃣ Project Setup

To set up the project with all required dependencies, please refer to the setup.md file in the repository. Once set up, we can directly jump into implementing our session-based authentication system.

What We'll Implement

In the following sections, we will build:

  • Session creation and cookie management
  • Auth and refresh middlewares
  • Cleanup and pruning logic for user sessions
  • Login and logout functionality

What's Next?

In the next sections, we'll dive into the code and build:

  1. Login handler that creates sessions
  2. Session middleware to protect routes and update session details
  3. Session limiting and cleanup logic
  4. Logout functionality

3️⃣ Writing the Login Handler

The login endpoint is responsible for verifying user credentials and creating a session upon success. Here's how our handler looks using Gin:

func LoginHandler(c *gin.Context) {
  var body struct {
    Email    string `json:"email" binding:"required"`
    Password string `json:"password" binding:"required"`
  }

  if err := c.ShouldBindJSON(&body); err != nil {
    response.Error(c, 400, "Invalid Request Body")
    return
  }

  userInfo, authErr := utils.AuthenticateUser(body.Email, body.Password)
  if authErr != nil {
    response.Error(c, 401, authErr.Error())
    return
  }

  if sessionCreationError := utils.CreateSession(c, userInfo); sessionCreationError != nil {
    response.Error(c, 500, "Internal Server Error")
    return
  }

  response.Success(c, 200, "Login Successful", gin.H{
    "userId": userInfo.ID,
  })
}

The logic is straightforward:

  • Parse and validate JSON body with ShouldBindJSON
  • Authenticate user with AuthenticateUser
  • On success, call CreateSession to register a session
  • Respond with success and return basic user info

The real magic happens inside CreateSession β€” that's where the session is generated, stored, and the cookie is set.

4️⃣ The CreateSession Function

This function is the heart of our session-based auth system. It takes care of creating a session, linking it to the user, pruning older sessions, and finally setting a secure cookie in the response.

func CreateSession(c *gin.Context, user *model.User) error {
  ctx := c.Request.Context()
  now := time.Now()
  sessionID := UUID()
  sessionKey := RedisSessionKey(sessionID)

  // First, clear expired sessions as a safety check
  // This also makes space by removing unnecessary sessions
  _ = CleanupUserExpiredSessions(user.ID)

  // Create the session metadata
  sessionMeta := model.SessionMeta{
    SID:            sessionID,
    UserID:         user.ID,
    CreatedAt:      now,
    LastAccessedAt: now,
    IP:             c.ClientIP(),
    UserAgent:      c.Request.UserAgent(),
  }

  // Store the session in Redis with expiration
  if err := SetSession(ctx, sessionKey, sessionMeta, time.Duration(constants.SessionMaxAge)*time.Second); err != nil {
    log.Printf("failed to register session: %v", err)
    return err
  }

  // Link this session to the user's session set
  if err := AddSessionToUser(ctx, user.ID, sessionID); err != nil {
    // If adding fails, rollback the session to keep data consistent
    if delErr := ClearSession(ctx, sessionID); delErr != nil {
      log.Printf("error: failed to rollback session %s after AddSessionToUser failed: %v", sessionID, delErr)
    }
    return fmt.Errorf("failed to register session: %w", err)
  }

  // If you want to cap the number of active sessions a user can have,
  // prune older sessions exceeding the allowed maximum
  if err := PruneExcessSessions(ctx, user.ID); err != nil {
    return fmt.Errorf("failed to create session: %w", err)
  }

  // Finally, set a secure HTTP-only cookie in the user's browser
  http.SetCookie(c.Writer, &http.Cookie{
    Name:     constants.SessionCookieName,
    Value:    sessionID,
    Path:     "/",
    HttpOnly: true,
    Secure:   true,
    MaxAge:   constants.SessionMaxAge,
    SameSite: http.SameSiteNoneMode,
  })

  return nil
}

Helper Functions

// SetSession stores the session metadata in Redis with an expiration
func SetSession(ctx context.Context, sessionKey string, session model.SessionMeta, expiration time.Duration) error {
	// Convert the session struct to JSON for storage in Redis
	sessionJSON, _ := json.Marshal(session)

	// Store the JSON in Redis with a TTL (expiration)
	if err := db.RedisClient.Set(ctx, sessionKey, sessionJSON, expiration).Err(); err != nil {
		return fmt.Errorf("failed to set session %s: %w", sessionKey, err)
	}

	return nil
}

// AddSessionToUser links the session ID to a user's set of active sessions
func AddSessionToUser(ctx context.Context, userID uint, sessionID string) error {
	// Key for storing all sessions of a specific user
	userSessionKey := RedisUserSessionsKey(userID)
	now := time.Now()

	// Use a sorted set with timestamp as score to track session creation time
	z := redis.Z{
		Score:  float64(now.Unix()), // Allows pruning older sessions later
		Member: sessionID,           // The session ID being added
	}

	// Add the session ID to the user's sorted set in Redis
	if err := db.RedisClient.ZAdd(ctx, userSessionKey, z).Err(); err != nil {
		return fmt.Errorf("failed to add session %s to user %v: %w", sessionID, userID, err)
	}

	return nil
}

Understanding the Process

  1. Generate a unique session ID using UUID()
  2. Clean up any expired sessions for the same user
  3. Create a SessionMeta object storing details like IP, user-agent, and timestamps
  4. Store the session in Redis with an expiry defined by SessionMaxAge
  5. Link this session to the user's session set using AddSessionToUser
  6. Remove older sessions if the user has exceeded the allowed maximum
  7. Finally, set a secure HTTP-only cookie that stores the session ID in the client's browser

Why This Matters

  • The session cookie replaces the need for sending tokens in every request
  • Since session data lives on the server, you can instantly revoke it anytime
  • With Redis TTL, sessions automatically expire β€” no cron jobs needed

At this point, the user is officially logged in. Their browser now carries a session cookie that will be sent automatically with each request, allowing your middleware to validate and authorize requests.

Next Up: Session Validation

Now that we can create sessions and issue cookies, the next step is to verify them on every request. We'll use two middlewares:

  • Auth Middleware: Checks whether the session is valid and ensures the user has access to protected routes
  • Session Refresh Middleware: Updates the session's LastAccessedAt timestamp on every request, even for routes that don't require authentication. This keeps the session alive while the user is actively using the app

Some APIs don't require full auth checks but still want to refresh the session's last accessed timestamp to prevent it from expiring. This way, we can keep sessions active without forcing a login for every non-sensitive request.

5️⃣ Session Middlewares: Auth & Refresh

Once sessions are created, we handle them on every request using two middlewares:

1 - AuthMiddleware

Protects routes that require authentication. It validates the session and injects user info into the request context.

func AuthMiddleware() gin.HandlerFunc {
  return func(c *gin.Context) {
    ctx := c.Request.Context()

    // Extract session ID from cookie
    sessionID := utils.GetSessionIDFromContext(c)
    if sessionID == "" {
      response.Error(c, http.StatusUnauthorized, "Missing session ID")
      c.Abort()
      return
    }

    // Fetch session metadata from Redis
    meta, err := utils.GetSessionDataBySID(ctx, sessionID)
    if err != nil || meta == nil {
      // If session exists but fetching fails, clear session and remove from user's session set
      if meta != nil {
        if err := utils.ClearSession(ctx, sessionID); err != nil {
          log.Printf("warn: failed to clear session %s: %v", sessionID, err)
        }
        if err := utils.ClearSessionFromUserSessions(ctx, meta.UserID, sessionID); err != nil {
          log.Printf("warn: failed to remove session %s from user %d: %v", sessionID, meta.UserID, err)
        }
      }

      // Delete the cookie and return unauthorized
      utils.DeleteCookie(c)
      response.Error(c, http.StatusUnauthorized, "Your session has expired. Please log in again.")
      c.Abort()
      return
    }

    // Inject session info and user ID into context for downstream handlers
    c.Set("session", meta)
    c.Set("uid", meta.UserID)

    c.Next()
  }
}

2 - RefreshSession

Keeps sessions alive by updating the LastAccessedAt timestamp. Useful for routes that don't require strict authentication.

func RefreshSession() gin.HandlerFunc {
  return func(c *gin.Context) {
    ctx := c.Request.Context()

    // Extract session ID from cookie
    sessionID := utils.GetSessionIDFromContext(c)
    if sessionID != "" {
      // Refresh session TTL in Redis
      if err := utils.RefreshSessionTTL(ctx, sessionID); err == nil {
        // Update the cookie in the user's browser to keep it in sync
        utils.RefreshCookie(c, sessionID)
      }
    }

    // Continue to next middleware or handler
    c.Next()
  }
}
  
func RefreshSessionTTL(ctx context.Context, sessionID string) error {
	meta, err := GetSessionDataBySID(ctx, sessionID)
	if err != nil || meta == nil {
		return errors.New("invalid session ID")
	}

	now := time.Now()

	// 1. Update TTL of session:<sid>
	sessionKey := RedisSessionKey(sessionID)
	if err := db.RedisClient.Expire(ctx, sessionKey, time.Duration(constants.SessionSlideAge)*time.Second).Err(); err != nil {
		return fmt.Errorf("failed to refresh session TTL: %w", err)
	}

	// 2. Update score in user_sessions:<uid>
	userKey := RedisUserSessionsKey(meta.UserID)
	if err := db.RedisClient.ZAdd(ctx, userKey, redis.Z{Score: float64(now.Unix()), Member: sessionID}).Err(); err != nil {
		return fmt.Errorf("failed to update session score in user set: %w", err)
	}

	// 3. Update LastAccessedAt in session metadata
	meta.LastAccessedAt = now
	if err := SetSession(ctx, sessionKey, *meta, time.Duration(constants.SessionSlideAge)*time.Second); err != nil {
		return fmt.Errorf("failed to update session metadata: %w", err)
	}

	return nil
}

// Refresh cookie expiry
func RefreshCookie(c *gin.Context, sessionID string) {
	http.SetCookie(c.Writer, &http.Cookie{
		Name:     constants.SessionCookieName,
		Value:    sessionID,
		Path:     "/",
		HttpOnly: true,
		Secure:   true,
		MaxAge:   constants.SessionSlideAge,
		SameSite: http.SameSiteNoneMode,
	})
}

By separating authentication and session refresh into two middlewares, we can enforce strict auth on sensitive routes while keeping sessions alive for all active users.

6️⃣ Session Cleanup & Pruning Strategy

Managing active sessions involves two key tasks: removing expired sessions (cleanup) and limiting the number of active sessions per user (pruning). Let's explore why we made specific design decisions.

Approach 1: Cleanup & Pruning Before Login

Flow: Remove expired sessions and prune old sessions first, then attempt login.

Issue: If login fails, valid sessions could be pruned unnecessarily, causing potential session loss for the user.

Approach 2: Cleanup & Pruning After Login

Flow: Attempt login first, then remove expired sessions and prune excess sessions.

Issue: Pruning is fine, but cleanup is essential. If login fails, expired sessions remain in the system longer than necessary.

βœ… Best Approach: Cleanup at Start, Pruning at End

  • Cleanup at Start: Removes expired sessions before login. Keeps the session list clean even if login fails.
  • Pruning at End: Limits the number of active sessions after successfully adding a new session. Prevents removing valid sessions prematurely.

This approach ensures both safety and correctness: expired sessions are always cleaned up, and the active session cap is enforced only when needed.

7️⃣ Session Cleanup & Pruning Logic

To keep a user's session list accurate and enforce limits, we use two helper functions: CleanupUserExpiredSessions and PruneExcessSessions.

CleanupUserExpiredSessions

Iterates through a user's session IDs and removes any that have expired in Redis. This ensures the session list stays clean even if login fails.

func CleanupUserExpiredSessions(userID uint) error {
	ctx := context.Background()
	userKey := RedisUserSessionsKey(userID)

	// Fetch all session IDs for the user
	sessionIDs, err := db.RedisClient.ZRange(ctx, userKey, 0, -1).Result()
	if err != nil {
		return fmt.Errorf("failed to fetch user sessions: %w", err)
	}

	for _, sid := range sessionIDs {
		sessionKey := RedisSessionKey(sid)

		// Check if the session key still exists
		exists, err := db.RedisClient.Exists(ctx, sessionKey).Result()
		if err != nil {
			continue // ignore errors and continue cleanup
		}

		// Remove expired sessions from user's set
		if exists == 0 {
			_ = db.RedisClient.ZRem(ctx, userKey, sid).Err()
		}
	}

	return nil
}

PruneExcessSessions

Ensures that a user does not exceed the maximum allowed active sessions. Removes the oldest sessions if necessary.

func PruneExcessSessions(ctx context.Context, userID uint) error {
	userKey := RedisUserSessionsKey(userID)

	// Get the number of active sessions
	length, err := db.RedisClient.ZCard(ctx, userKey).Result()
	if err != nil {
		return fmt.Errorf("failed to get session count: %w", err)
	}

	if length > int64(constants.MaxUserSessions) {
		excessSessions := length - int64(constants.MaxUserSessions)

		// Fetch the oldest sessions to remove
		oldSessions, err := db.RedisClient.ZRange(ctx, userKey, 0, excessSessions-1).Result()
		if err != nil {
			return fmt.Errorf("failed to prune excess sessions: %w", err)
		}

		for _, oldID := range oldSessions {
			// Remove session ID from user's session set
			if err := ClearSessionFromUserSessions(ctx, userID, oldID); err != nil {
				log.Printf("warn: failed to unlink session %s from user %v: %v", oldID, userID, err)
			}
			// Delete session key from Redis
			if err := ClearSession(ctx, oldID); err != nil {
				log.Printf("warn: failed to delete session %s: %v", oldID, err)
			}
		}
	}

	return nil
}

These two functions work together: cleanup keeps the session list accurate at the start of login, while pruning enforces the session limit at the end.

8️⃣ Logout Functionality

Logging out a user involves clearing the session data from Redis, removing the session ID from the user's active session list, and deleting the browser cookie.

DeleteCookie

Deletes the session cookie from the browser by setting its value to empty and expiration in the past.

func DeleteCookie(c *gin.Context) {
	http.SetCookie(c.Writer, &http.Cookie{
		Name:     constants.SessionCookieName,
		Value:    "",
		Path:     "/",
		HttpOnly: true,
		Secure:   true,
		MaxAge:   -1,
		SameSite: http.SameSiteNoneMode,
		Expires:  time.Unix(0, 0), // set expiration in the past
	})
}

ClearSession & ClearSessionFromUserSessions

Removes session data from Redis and removes the session ID from the user's set of active sessions.

func ClearSession(ctx context.Context, sessionID string) error {
	sessionKey := RedisSessionKey(sessionID)
	if err := db.RedisClient.Del(ctx, sessionKey).Err(); err != nil {
		return fmt.Errorf("failed to delete session %s: %w", sessionKey, err)
	}
	return nil
}

func ClearSessionFromUserSessions(ctx context.Context, userID uint, sessionID string) error {
	userKey := RedisUserSessionsKey(userID)
	if err := db.RedisClient.ZRem(ctx, userKey, sessionID).Err(); err != nil {
		return fmt.Errorf("failed to remove session %s from user %d sessions: %w", sessionID, userID, err)
	}
	return nil
}

LogoutSession

Handles the full logout process: clears session data, removes session ID from user's list, and deletes the browser cookie.

func LogoutSession(c *gin.Context) error {
	ctx := c.Request.Context()

	// Retrieve session metadata from request context
	session, err := GetSessionFromContext(c)
	if err != nil {
		return fmt.Errorf("failed to retrieve session from context: %w", err)
	}

	// Delete session data from Redis
	if err := ClearSession(ctx, session.SID); err != nil {
		return fmt.Errorf("failed to delete session %s: %w", session.SID, err)
	}

	// Remove session ID from user's active session set
	if err := ClearSessionFromUserSessions(ctx, session.UserID, session.SID); err != nil {
		log.Printf("warn: failed to remove session %s from user %d sessions: %v", session.SID, session.UserID, err)
	}

	// Delete the session cookie from the browser
	DeleteCookie(c)
	return nil
}

This ensures that the user's session is fully terminated both server-side and client-side, providing a clean and secure logout experience.

9️⃣ Extending Session Management

As we approach the end of this tutorial, the possibilities are endless! You can add more features to our session-based management system to make it even more secure, flexible, and user-friendly.

Endpoints You Can Try

  • Get all active sessions β€” see where a user is logged in
  • Delete a specific session β€” log out from a single device
  • Logout from all sessions β€” useful if an account is compromised
  • Extend session expiration β€” "keep me logged in" feature
  • Notify users on new login β€” email or push notification for new devices

Security & Monitoring Features to Explore

  • IP-based session checks β€” detect suspicious logins from new locations
  • User-agent verification β€” ensure consistent device usage
  • Geo-location restrictions β€” prevent login from restricted countries
  • Session activity logging β€” track what actions happen in each session

The goal is to keep sessions secure, give users control, and make your system flexible. You can combine these features or come up with new ideas depending on your app's requirements.

You can check out the full implementation of this session-based auth system from scratch on GitHub: https://github.com/anurag-327/session-based-auth

avatar
Anurag Srivastav
Founding Engineer @ Codolio
Note: Everything shared in this blog comes from my current understanding and experience. I write to document my learnings. If you have feedback or suggestions, I'd love to hear them!
Auth Beyond Tokens: Implementing Session Lifecycle