Sticky sessions and session affinity
Cookie insertion, source IP hashing and consistent hashing compared across five proxies, with the security, autoscaling and draining costs of affinity.
Key points
- Session affinity is a workaround for state living in one server's memory; it constrains scheduling to hide a design decision.
- Cookie insertion is the only mechanism that is per-browser accurate; source IP hashing fails behind CGNAT, corporate NAT and mobile networks.
- Consistent hashing (ring hash, maglev) remaps roughly
1/Nof keys when the backend set changes; modulo hashing remaps nearly all of them. - Affinity cookies routinely leak backend hostnames. Use opaque or keyed values and set
Secure,HttpOnlyandSameSite.
Session affinity, or sticky sessions, makes a load balancer send every request from one client to the same backend. It exists for one reason: state the request needs lives in a single server's memory rather than in a store all servers can reach. Affinity is a scheduling constraint compensating for an application design decision, and every problem it causes (uneven load, sessions lost on deploy, autoscaling that does not relieve pressure) follows from that. The right default is a stateless application with a shared session store; affinity should be a performance optimisation you choose, not a correctness requirement you inherit.
The mechanisms, compared#
| Mechanism | Granularity | Survives backend set change | Works behind NAT/CGNAT | Visible to client | Example |
|---|---|---|---|---|---|
| Cookie insertion | per browser profile | no (cookie names a specific server) | yes | yes, a new Set-Cookie | HAProxy cookie SRV insert indirect nocache |
| Cookie prefix / rewrite | per browser profile | no | yes | modifies an existing app cookie | HAProxy cookie JSESSIONID prefix |
| Source IP hash (modulo) | per source address | no, nearly all clients remap | no | no | nginx ip_hash |
| Consistent hash on IP | per source address | yes, about 1/N remap | no | no | HAProxy balance source + hash-type consistent |
| Consistent hash on cookie or header | per key | yes, about 1/N remap | yes | only if the key already exists | nginx hash $cookie_sid consistent |
| Maglev | per key | yes, near-minimal disruption, even spread | yes | no | Envoy lb_policy: MAGLEV |
| Encoded upstream address | per browser profile | no | yes | yes | Envoy stateful_session with CookieBasedSessionState |
The column that decides most designs is "survives backend set change". Cookie insertion names a specific server, so when that server disappears the affinity is void and the user is rehashed to a random peer, losing whatever state made affinity necessary. Consistent hashing keeps affinity statistically stable across membership changes but never guarantees any particular client stays put.
Cookie-based affinity by proxy#
HAProxy#
HAProxy has the most complete implementation, and its three modes are worth knowing because they solve different problems.
backend app
balance roundrobin
cookie SRV insert indirect nocache httponly secure attr "SameSite=Lax"
server s1 10.0.1.10:8080 check cookie s1
server s2 10.0.1.11:8080 check cookie s2insertadds a new cookie owned by HAProxy.indirectstrips it from the request before forwarding, so the backend never sees it.nocacheaddsCache-control: private, which matters more than it looks: without it a shared cache can store a response carrying someone'sSet-Cookie: SRV=s1and hand that identity to other users. With a cache in front of the proxy, read caching in reverse proxies first.prefixprepends the server identifier to an existing application cookie (JSESSIONID=s1~abc123) and strips the prefix on the way in. It adds no extra cookie, which helps with cookie-count limits and consent policies, but it breaks if the application regenerates the session cookie on privilege change, which well-behaved applications do at login.rewritereplaces the value of a cookie the application already sets, and so requires the application to always set it.
dynamic with a dynamic-cookie-key derives the cookie value from the server address and a secret rather than from the name you typed, which is the fix for the hostname leak discussed below. Surrounding syntax is in HAProxy configuration for HTTP reverse proxying.
nginx#
The sticky cookie, sticky route and sticky learn directives were nginx Plus only until 1.29.6, which moved them into the open source build (the sync parameter of sticky learn stays commercial). On older builds, or when you would rather keep no affinity state in the proxy, use ip_hash and the general-purpose hash directive:
upstream app {
hash $cookie_sessionid consistent;
server 10.0.1.10:8080;
server 10.0.1.11:8080;
}This hashes an existing application cookie rather than inserting one. It is a good option: no state at the proxy, and identical behaviour across replicated nginx instances. The caveat is that requests made before the session cookie exists, notably the login POST, are unpinned, so the login handler must tolerate landing anywhere.
Caddy#
reverse_proxy 10.0.1.10:8080 10.0.1.11:8080 {
lb_policy cookie lb_sess "a-long-random-secret"
}Caddy's cookie policy derives the value using the supplied secret, so the client sees an opaque token rather than an upstream address. Supply the secret explicitly: if Caddy generates one, the value is not stable across a restart or across multiple Caddy instances.
Traefik#
labels:
- "traefik.http.services.app.loadbalancer.sticky.cookie=true"
- "traefik.http.services.app.loadbalancer.sticky.cookie.name=srv"
- "traefik.http.services.app.loadbalancer.sticky.cookie.secure=true"
- "traefik.http.services.app.loadbalancer.sticky.cookie.httpOnly=true"
- "traefik.http.services.app.loadbalancer.sticky.cookie.sameSite=lax"Traefik exposes secure, httpOnly, sameSite and maxAge as first-class options. Older versions placed a readable backend URL in the cookie value and newer ones hash it, so confirm what your version emits by inspecting an actual Set-Cookie.
Envoy#
Envoy offers two approaches. The stateful_session filter with CookieBasedSessionState encodes the chosen upstream host's address into a cookie and routes straight to it, overriding the load balancing policy. Alternatively RING_HASH or MAGLEV as the cluster lb_policy, with a route hash_policy on a cookie, header or source IP, gives hash-based affinity with no per-session state:
route:
cluster: app
hash_policy:
- cookie:
name: sessionid
ttl: 3600sIf the named cookie is absent Envoy can generate one with the given TTL, giving cookie insertion and consistent hashing at once.
Why source IP hashing is the wrong default#
Hashing the client address needs no cookies and works for non-HTTP traffic, which is why every proxy still offers it. It is wrong for most public traffic, for four reasons.
- CGNAT and corporate egress. Thousands of subscribers behind one carrier-grade NAT address, or an office behind one egress IP, hash to a single backend. Adding servers does not fix the lumpiness.
- Mobile networks. A phone's public address changes on handoff between carrier gateways, so affinity silently breaks mid-session.
- You are usually hashing the proxy, not the client. Behind a CDN, nginx's
ip_hashuses$remote_addr, which is the edge address, and all traffic collapses onto a handful of hashes. Fixing it means resolving the true client address with the realip module and the X-Forwarded-For header, which is only safe with configured trusted proxies; otherwise a client picks its own backend by forging the header. - Coarse granularity. nginx documents that
ip_hashuses the first three octets of an IPv4 address and the whole IPv6 address. The coarseness keeps a client stable across reassignment inside a /24, but it also makes an entire /24 of users a single hash bucket.
Consistent hashing and why modulo fails#
Naive hash balancing computes hash(key) % N where N is the backend count. Change N from 4 to 5 and the mapping changes for the large majority of keys: almost every session moves, precisely during a scale event or a failure.
Consistent hashing places backends at many points on a hash ring and maps each key to the next backend clockwise. Removing one backend reassigns only the keys in its arcs, roughly 1/N of them. nginx implements this as hash KEY consistent (ketama-compatible), HAProxy as hash-type consistent, Envoy as RING_HASH with minimum_ring_size defaulting to 1024; too small a ring balances poorly because each backend holds too few points on the circle.
Maglev, from Google's published design, builds a fixed-size lookup table instead (Envoy uses 65537 entries) with a permutation-based fill that yields a more even distribution and constant-time lookups, at the cost of slightly more disruption than ring hash on membership change.
Header and route based affinity#
Hashing something semantic rather than something incidental is usually better than session affinity, because it is stable, meaningful and testable. Hash on a tenant identifier, a shard key, a document ID or an API key:
upstream shards {
hash $http_x_tenant_id consistent;
server 10.0.1.10:8080;
server 10.0.1.11:8080;
}Every request for one tenant reaches the same backend, so caches and prepared state stay warm, without depending on a browser keeping a cookie. It survives logout, incognito windows and non-browser clients, none of which cookie affinity handles, and the correctness requirement is weaker: if the key hashes elsewhere the request is slower, not broken.
Security and privacy of affinity cookies#
Affinity cookies are an information disclosure channel and a targeting mechanism.
- They leak backend identity. A cookie value of
web-prod-03, or a base64 string that decodes to10.0.1.11:8080, tells an attacker how many backends exist, their naming scheme and often their internal addressing. Use HAProxy'sdynamiccookies, Caddy's keyed cookie policy, or opaque server labels (cookie a,cookie b) rather than hostnames. - They let an attacker choose a backend. With a readable mapping an attacker can pin to one instance: useful for probing a canary running different code, exhausting a single node, or confirming a vulnerable node exists. Keyed values prevent enumeration but not stickiness to whatever node they landed on.
- Set the attributes.
Secure,HttpOnlyand an explicitSameSite. Browsers rejectSameSite=NonewithoutSecure, so that combination silently disables affinity. - Mark the response uncacheable. Any response carrying a per-user
Set-Cookiemust not be stored by a shared cache; HAProxy'snocacheexists for this. - Bound the lifetime. A
Max-Agelonger than the session it supports just pins a returning user to a machine decommissioned months ago.
Interaction with autoscaling, deploys and draining#
Affinity and elasticity pull against each other, and the friction is concrete.
Scale-out does not relieve load. New instances receive only new sessions, so instances that were hot enough to trigger scaling stay hot for as long as their sessions live. Cookie affinity makes this absolute; consistent hashing at least reassigns a share of keys immediately.
Scale-in and deploys destroy sessions. Removing an instance voids every affinity cookie pointing at it, and with in-memory sessions those users are logged out. Nothing is broken: the state simply did not exist anywhere else.
Draining is what makes affinity survivable. A drain state accepts requests carrying an existing affinity marker while refusing new ones, so the instance empties as sessions expire. HAProxy distinguishes this explicitly: set server app/s1 state drain keeps serving persistent sessions while excluding the server from new load balancing decisions, whereas maint cuts it off entirely. Sequence a rollout as drain, wait for the session TTL up to a bounded maximum, then terminate. Note the interaction with health checks and upstream failover: a server marked down by a health check is not drained, it is dropped, and its sessions go with it.
Failure modes#
Users randomly logged out, worse during deploys. Affinity is working as designed and the backend holding the session went away. The fix is not a longer cookie lifetime, it is moving session state out of process memory.
One backend at 90% CPU, the rest idle, with ip_hash. You are hashing a NAT gateway or a CDN edge. Verify by logging $remote_addr and counting distinct values, then switch to a cookie or a semantic key.
Affinity works in testing, disappears behind the CDN. Check whether an intermediary strips or rewrites Set-Cookie, and whether the cookie's Domain and Path match the production hostname: a cookie set for app.example.com is not sent to www.example.com.
Set-Cookie present, affinity still not applied. In HAProxy prefix mode the application may be regenerating its session cookie on every response and wiping the prefix; in rewrite mode it may not set the cookie on some paths at all.
One user's affinity cookie served to everyone. A shared cache stored a response containing Set-Cookie. Add nocache (HAProxy) or ensure Cache-Control: private on any response that sets an affinity cookie.
Load skewed badly with ring hash and few backends. The ring is too small. Raise minimum_ring_size in Envoy, increase weight granularity, or switch to maglev.
A decision rule#
- Default to stateless. Put session state in a shared store or in a signed, encrypted cookie held by the client. Any request can then go anywhere and every problem above disappears.
- Use affinity for cache locality. If a backend keeps an expensive per-key in-memory cache, hash a semantic key with a consistent hash policy and treat a miss as a slow request, never an error.
- Use affinity where the connection is the state. WebSockets, gRPC streams, SSE and long-poll are already pinned to whichever backend accepted them.
- If affinity is load-bearing for correctness, bound the blast radius. Short cookie lifetimes, opaque keyed values, a drain state in every deploy, and code that treats a lost session as ordinary rather than exceptional.
Frequently asked questions#
Does nginx open source support sticky sessions?#
Since 1.29.6, yes: the sticky cookie, sticky route and sticky learn directives moved into the open source build in that release, having been nginx Plus features before it. On older builds you have ip_hash and the generic hash KEY consistent directive, which can hash an application cookie that the backend already sets.
What is the difference between HAProxy cookie insert, prefix and rewrite?#
insert makes HAProxy add its own cookie, which is independent of the application. prefix prepends a server identifier to an existing application cookie and strips it on the way back in. rewrite replaces the value of a cookie the application always sets. insert is the usual choice because it does not depend on application behaviour.
Why does ip_hash send all my traffic to one backend?#
Because the address being hashed is not the client's. Behind a CDN, a load balancer or a corporate NAT, the proxy sees a small number of source addresses, so nearly all requests hash to the same backend. Resolve the real client IP from X-Forwarded-For using a trusted proxy list, or switch to cookie-based affinity.
Is consistent hashing better than modulo hashing?#
Yes, whenever the backend set can change. Modulo hashing remaps almost every key when the server count changes; consistent hashing remaps roughly one Nth. Modulo is only acceptable for a fixed set of backends.
Do sticky sessions break autoscaling?#
They limit it. New instances receive only new sessions, so scaling out does not reduce load on already-hot instances until existing sessions expire. Consistent hashing reassigns a share of traffic immediately; cookie insertion does not.
What is the difference between ring hash and maglev?#
Ring hash minimises how many keys move when the backend set changes but can distribute load unevenly if the ring is small or weights are uneven. Maglev builds a fixed-size lookup table that spreads load very evenly and looks up faster, at the cost of moving slightly more keys during membership changes.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
- HAProxy configuration manual (cookie, balance, hash-type)
- nginx ngx_http_upstream_module (ip_hash, hash, sticky)
- Caddy reverse_proxy load balancing policies
- Traefik sticky sessions
- Envoy stateful session filter
- Envoy load balancers (ring hash, maglev)
- RFC 6265bis Cookies (SameSite)
- Maglev: A Fast and Reliable Software Network Load Balancer
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.