Auth Beyond Tokens
Implementing Session Lifecycle

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
- User submits email and password
- Server validates the credentials against the database
- Server generates a random session ID
- Server stores the session in Redis under a key like
session:abc123 - Server also records this session ID in a set under
user_sessions:42(so we know all active sessions for user 42) - Server sends the session ID to the browser as a cookie
- On the next request, the browser automatically sends the cookie
- 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 detailsuser_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:
- Login handler that creates sessions
- Session middleware to protect routes and update session details
- Session limiting and cleanup logic
- 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
CreateSessionto 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
- Generate a unique session ID using
UUID() - Clean up any expired sessions for the same user
- Create a
SessionMetaobject storing details like IP, user-agent, and timestamps - Store the session in Redis with an expiry defined by
SessionMaxAge - Link this session to the user's session set using
AddSessionToUser - Remove older sessions if the user has exceeded the allowed maximum
- 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
LastAccessedAttimestamp 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
