Reverse proxy

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.

· 12 min read · How we verify this

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/N of 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, HttpOnly and SameSite.

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#

MechanismGranularitySurvives backend set changeWorks behind NAT/CGNATVisible to clientExample
Cookie insertionper browser profileno (cookie names a specific server)yesyes, a new Set-CookieHAProxy cookie SRV insert indirect nocache
Cookie prefix / rewriteper browser profilenoyesmodifies an existing app cookieHAProxy cookie JSESSIONID prefix
Source IP hash (modulo)per source addressno, nearly all clients remapnononginx ip_hash
Consistent hash on IPper source addressyes, about 1/N remapnonoHAProxy balance source + hash-type consistent
Consistent hash on cookie or headerper keyyes, about 1/N remapyesonly if the key already existsnginx hash $cookie_sid consistent
Maglevper keyyes, near-minimal disruption, even spreadyesnoEnvoy lb_policy: MAGLEV
Encoded upstream addressper browser profilenoyesyesEnvoy 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.

HAProxy#

HAProxy has the most complete implementation, and its three modes are worth knowing because they solve different problems.

haproxy
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 s2
  • insert adds a new cookie owned by HAProxy. indirect strips it from the request before forwarding, so the backend never sees it. nocache adds Cache-control: private, which matters more than it looks: without it a shared cache can store a response carrying someone's Set-Cookie: SRV=s1 and hand that identity to other users. With a cache in front of the proxy, read caching in reverse proxies first.
  • prefix prepends 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.
  • rewrite replaces 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:

nginx
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#

text
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#

yaml
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:

yaml
route:
  cluster: app
  hash_policy:
  - cookie:
      name: sessionid
      ttl: 3600s

If 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.

  1. 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.
  2. Mobile networks. A phone's public address changes on handoff between carrier gateways, so affinity silently breaks mid-session.
  3. You are usually hashing the proxy, not the client. Behind a CDN, nginx's ip_hash uses $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.
  4. Coarse granularity. nginx documents that ip_hash uses 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:

nginx
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 to 10.0.1.11:8080, tells an attacker how many backends exist, their naming scheme and often their internal addressing. Use HAProxy's dynamic cookies, 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, HttpOnly and an explicit SameSite. Browsers reject SameSite=None without Secure, so that combination silently disables affinity.
  • Mark the response uncacheable. Any response carrying a per-user Set-Cookie must not be stored by a shared cache; HAProxy's nocache exists for this.
  • Bound the lifetime. A Max-Age longer 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#

  1. 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.
  2. 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.
  3. Use affinity where the connection is the state. WebSockets, gRPC streams, SSE and long-poll are already pinned to whichever backend accepted them.
  4. 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.

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.

  1. HAProxy configuration manual (cookie, balance, hash-type)
  2. nginx ngx_http_upstream_module (ip_hash, hash, sticky)
  3. Caddy reverse_proxy load balancing policies
  4. Traefik sticky sessions
  5. Envoy stateful session filter
  6. Envoy load balancers (ring hash, maglev)
  7. RFC 6265bis Cookies (SameSite)
  8. 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.

More in reverse proxy configuration#