Cookies, fingerprinting, hijack detection
The session cookie is the credential a browser presents on every request. If an attacker captures it, they can act as the user until the session expires or is revoked. The defences are layered: cookie attributes constrain how the browser handles the cookie, HMAC signing detects tampering, fingerprint binding catches replay from a different browser, and trusted-proxy configuration controls how the application reads the request's IP. This chapter covers each layer.
Cookie attributes
The session cookie carries five attributes the deployment cares
about. Most have defaults that are right for production; one
(Secure) needs to be set explicitly.
Path=/ makes the cookie apply to the whole application. The
alternative (a narrower path) is occasionally useful for embedded
deployments where the application lives under a sub-path of a
larger site; for most deployments, the root path is right.
HttpOnly prevents client-side JavaScript from reading the
cookie. The attribute defeats one class of cross-site scripting
attack: an attacker who injects JavaScript into the page cannot
read the session cookie through document.cookie and exfiltrate
it. The attribute is on by default and there is rarely a reason to
turn it off.
SameSite controls when the browser sends the cookie on
cross-origin requests. There are three values:
Strictmeans the cookie is sent only on same-site requests. A link from an external site to your application produces a guest-state request even if the user is logged in; the user must navigate from within your site for the session to be recognised.Lax(the default) means the cookie is sent on top-level cross-site navigations (a link click) but not on cross-site sub-requests (an embedded image, an XHR). The combination defeats most CSRF attacks while preserving the user experience of "click an external link, arrive logged in."Nonemeans the cookie is sent on every cross-site request. This is the right setting when the application is embedded in iframes on third-party sites; it is the wrong setting otherwise.
The recommendation is Lax for most deployments. Switch to
Strict for the highest-sensitivity actions; the cost is the
user-experience friction of cross-site link arrivals not being
logged in.
Secure requires HTTPS. The cookie is sent only on TLS-protected
connections; a misconfigured load balancer that accepts cleartext
HTTP does not see the cookie. The attribute is non-negotiable for
production but breaks localhost development against http://,
which is why SessionLayer::with_secure(false) exists as a
development concession.
The Max-Age (the cookie's lifetime in seconds) matches the
session TTL from SessionLayer::with_ttl. The browser stops
sending the cookie after the lifetime expires; the server-side
session has its own expiry that the lifecycle layer also enforces.
HMAC signing
The cookie carries an HMAC signature computed from the session id and the deployment's signing key. The format is:
<base64(session_id)>.<base64(hmac_sha256(signing_key, session_id))>
The signature defeats forgery and tampering. An attacker who
guesses a session id (or who tries to mutate an existing cookie)
cannot produce a valid signature without the signing key. The
server rejects any cookie whose signature does not validate; the
session is not loaded and the request proceeds as Guest.
The HMAC verification is constant-time. The constant-time comparison defeats a timing attack where an attacker could distinguish "valid signature for invalid id" from "invalid signature for valid id" by measuring response latency.
The signing key rotation is the operational lever for replacing the
signing key without invalidating active sessions. The pattern is
covered in Operations runbook. The short version is:
SessionLayer::with_previous_key accepts the old key, sessions
signed with the old key continue to validate, sessions signed
with the new key (which is now what the layer uses for new
signings) are the new default. After enough time for all old
cookies to expire, the previous key is removed.
The fingerprint binding
The fingerprint is the additional signal that catches session replay from a different browser. The mechanism takes a few coarse features of the request (the user agent, the IP address, sometimes the accept-language), HMACs them together with a deployment-level pepper, and stores the result alongside the session.
let fingerprint = hmac_sha256(
fingerprint_pepper,
format!("{}|{}|{}",
user_agent,
client_ip,
accept_language,
),
);
The choice of features is deliberate. They are coarse enough that the legitimate user's browser produces the same fingerprint across ordinary requests (the user agent does not change between requests, the IP is within the same prefix, the accept-language is stable), and specific enough that an attacker replaying the cookie from a different machine produces a different fingerprint.
The tolerance is the operational lever. Strict matching produces too many false positives (a user switching from wifi to cellular sees their IP change, a browser auto-update changes the user agent string). Coarse matching produces too few signals to detect replay. The default tolerance:
- IP: same /24 for IPv4, same /64 for IPv6.
- User agent: same major version of the same browser.
- Accept-language: same primary language.
A request that matches within the tolerance passes. A request that diverges beyond it produces an event the policy decides what to do with.
The policy has three options:
-
FingerprintPolicy::Warnlogs the mismatch and lets the request proceed. This is the right setting during initial rollout when the tolerance is being calibrated; the logs show how often legitimate users trigger mismatches, and the tolerance can be adjusted. -
FingerprintPolicy::Reauthreturns 401 and clears the session. The user has to log in again. This is the right setting for high-sensitivity actions; the user accepts the friction of re-authentication in exchange for the assurance that a captured cookie does not get away with the session. -
FingerprintPolicy::Revokedeletes the session entirely. The user is logged out, and their other sessions remain. This is the right setting when fingerprint mismatch is a strong signal of compromise; the deployment treats it as the user being hijacked and ends the session immediately.
The default is Warn. Lift to Reauth once the warn rate is
below your tolerance.
The pepper is a deployment-level secret stored alongside the session signing key. It defeats fingerprint synthesis: an attacker who knows the features (the user's IP, their user agent) cannot construct the fingerprint without the pepper, so they cannot adjust their replay to match.
Trusted-proxy configuration
The fingerprint depends on the request's IP being accurate. In
many deployments the application sits behind one or more proxies
(a load balancer, a CDN, a WAF), and the request's source IP is
the proxy's IP, not the user's. The user's IP is in a forwarded
header like X-Forwarded-For.
Reading the forwarded header is necessary but dangerous. A
deployment that trusts the header without checking the source can
be spoofed: a request directly to the application with a forged
X-Forwarded-For header will be treated as if it came through
the proxy.
The defence is the trusted-proxy configuration. The application configures which source IPs are trusted to set the header; the session layer reads the header only when the immediate request came from one of those IPs.
let layer = SessionLayer::new(store, signing_key)
.with_trusted_proxies(vec![
"10.0.0.0/8".parse().unwrap(), // internal load balancer
"172.16.0.0/12".parse().unwrap(), // VPN range
])
.with_forwarded_header(ForwardedHeader::XForwardedFor);
The configuration accepts a list of CIDR ranges that the
deployment trusts. Requests from inside any range have their
X-Forwarded-For read; requests from outside any range use the
immediate connection's IP.
The forwarded-header choice is the application's. The standard is
X-Forwarded-For (a comma-separated list of IPs, the first being
the original client), but some deployments use Forwarded (RFC
7239) or a proxy-specific header. Axess supports all three;
configure the one the deployment uses.
For multi-hop proxy chains, the rule is the same. The request
came through lb → cdn → application; the application's immediate
peer is the CDN, the CDN's X-Forwarded-For lists lb and the
original client. If both the CDN and the LB are in trusted ranges,
the application takes the leftmost IP from the header (the
original client). If only the immediate peer is trusted, the
application takes the rightmost IP from the header (the next hop
back).
The configuration is deployment-specific. Get it wrong in either direction (trust too much, get spoofed; trust too little, see only proxy IPs) and the fingerprint binding becomes either fragile or useless.
Defending against XSS
The cookie's HttpOnly attribute defeats one class of XSS attack
(reading the cookie). It does not defeat all of them.
An attacker with JavaScript execution in the page can:
-
Submit requests on the user's behalf (the browser sends the cookie automatically). The defence is CSRF protection: most axess deployments use
tower-http's CSRF middleware, which requires a CSRF token on state-changing requests, and the token is not readable from JavaScript. -
Manipulate the page the user sees to phish credentials or to trick the user into actions. The defence is Content Security Policy (CSP) headers, which constrain what JavaScript the page can load and execute. CSP is an application-side concern, not a session-layer concern, but it composes with the session layer's defences.
The session layer's role is to constrain the cookie. The application's role is to constrain what JavaScript can do in the page. Both layers are needed; the session layer alone does not defend against XSS.
CSRF defences
The session cookie is sent on cross-origin top-level navigations
because SameSite=Lax allows it. An attacker can craft a link
that, when clicked from an external site, triggers a state change
in the user's session (the classic CSRF attack).
The SameSite=Lax default narrows the attack: it works only on
top-level GETs and on the Form element, not on XHR or fetch
calls. The defences against the remaining surface:
- Use POST (or PUT, DELETE, PATCH) for state-changing requests. GET requests should be safe.
- Add a CSRF token to state-changing forms. The token is set in
the session and read from a hidden form field; the server
checks that they match. Axess does not include a CSRF middleware
out of the box; the convention is to use
tower-http's middleware or to write a small one. - For applications that need cross-origin embedded use,
SameSite=Noneplus a strict CSRF token check is the combination.SameSite=NonerequiresSecure, so the combination is only deployable on HTTPS.
What goes wrong, and how to tell
Three failure modes recur during initial deployment.
The first is a cookie that the browser refuses to send. The
symptom is sessions that disappear between requests; the cause is
almost always either Secure=true on an http:// connection
(the browser refuses to send), SameSite=Strict on a cross-site
navigation that should have been recognised, or a Path that
does not match the request URL. Inspect the cookie's attributes
in the browser's dev tools.
The second is a fingerprint that diverges for the legitimate user.
The symptom is a Warn log every few sessions or a Reauth that
fires on every wifi-to-cellular switch. The cause is usually the
tolerance being too strict; widen the IP prefix or relax the
user-agent match. The right tolerance is the smallest one that
does not produce noise on legitimate traffic.
The third is the trusted-proxy configuration getting the wrong IP. The symptom is a fingerprint that matches when it should not (an attacker successfully replaying a cookie), or that diverges when it should match (a legitimate user being asked to re-authenticate). The cause is either an unintentionally trusted source (a debug endpoint left open, a VPN allowed to spoof the header) or an unintentionally untrusted proxy (the deployment forgot to add a new proxy's IP to the trusted list).
The pattern across all three: turn on the diagnostic logs, let the deployment run for a week, look at the warning rate, calibrate.
Further reading
Session lifecycle and crypto envelope covers the cookie shape and the orchestration that issues it. Backends covers the storage backends that persist the fingerprint alongside the session. Security posture covers the production crypto requirements that apply to the session layer, including the signing-key length and the FIPS-routing notes. Operations runbook covers signing-key, envelope-key, and fingerprint-pepper rotation.