Web sessions: cookies, CORS, CSRF and TLS
Generated by Claude (Fable 5.1) by hand from the identity and integration ops spike, Scout learning center on 2026-10-14, reviewed by a second reader (Claude Fable 5.1), Scout learning center on 2026-10-14.
Concept: HTTP, TLS and transport protocols · also covers Identity and access, Threat modelling and authorization design · Lesson 1 of 10 in Identity and integration ops · version 1
Practice thisOn this page
Every single sign-on bug report arrives as a browser symptom: a cookie that was not sent, a redirect that lost the session, a request the browser refused to make, or a certificate the customer's laptop does not trust. This lesson is the browser-side mechanics every login flow rides on. A session is a cookie; a cross-site redirect either carries it or does not, and SameSite decides; a single-page app calling a token endpoint on another origin needs CORS; CSRF is why OAuth has a state parameter and SAML has RelayState; TLS is the assumption under all of it. Learn these six things once and half of the troubleshooting runbook becomes obvious.
Read more
- guideMDN: Using HTTP cookies (developer.mozilla.org) checked 2026-10-14
- docMDN: Set-Cookie header (developer.mozilla.org) checked 2026-10-14
- guideMDN: Cross-Origin Resource Sharing (CORS) (developer.mozilla.org) checked 2026-10-14
How it is done in practice
On the first day of an SSO integration an implementation engineer opens the customer's login page with the browser's devtools, reads the Set-Cookie headers, and writes down which cookie carries the session and which attributes it has. When the customer's identity provider redirects back and the app says 'no session', the engineer checks SameSite before anything else. When the customer's single-page app gets a red CORS line in the console, the engineer knows it is the preflight, not the token, and asks which origins the provider allows. When a scripted test client cannot log in to a local Keycloak while the browser can, the engineer remembers that browsers treat 127.0.0.1 as a secure context and plain-http scripts do not. Each of those is a five-minute fix once the vocabulary is in place, and a two-day ticket when it is not.
Technologies
Diagram
sequenceDiagram participant B as Browser participant A as Your app participant I as Identity provider B->>A: GET /login A-->>B: 302 to the provider, Set-Cookie state (Secure, SameSite) B->>I: GET /authorize (cross-site navigation) I-->>B: login page, then redirect or auto-post form B->>A: GET or POST /callback with code or SAMLResponse Note over B,A: Lax sends the cookie on the GET, not on the POST A-->>B: Set-Cookie session (Secure, HttpOnly, SameSite=Lax)
sequenceDiagram participant B as Browser participant A as Your app participant I as Identity provider B->>A: GET /login A-->>B: 302 to the provider, Set-Cookie state (Secure, SameSite) B->>I: GET /authorize (cross-site navigation) I-->>B: login page, then redirect or auto-post form B->>A: GET or POST /callback with code or SAMLResponse Note over B,A: Lax sends the cookie on the GET, not on the POST A-->>B: Set-Cookie session (Secure, HttpOnly, SameSite=Lax)
Code
sh# Read the cookies a login page sets, without a browser
curl -si http://127.0.0.1:18080/realms/master/protocol/openid-connect/auth \
-G --data-urlencode 'client_id=account-console' --data-urlencode 'response_type=code' \
--data-urlencode 'redirect_uri=http://127.0.0.1:18080/realms/master/account/' \
| grep -i '^set-cookie'
# Set-Cookie: AUTH_SESSION_ID=...;Version=1;Path=/realms/master/;Secure;HttpOnly;SameSite=None
# Set-Cookie: KC_RESTART=...;Version=1;Path=/realms/master/;Secure;HttpOnly;SameSite=None (Keycloak 26.4, as run)
# The TLS lines of a real connection: version, cipher, certificate subject and issuer
curl -sv https://example.com -o /dev/null 2>&1 | grep -E 'SSL connection|subject:|issuer:'
python# A scripted client on plain http never sends a Secure cookie; a browser treats 127.0.0.1 as a secure context.
# The learning center's test client had to clear the flag to get past a local Keycloak login page.
import requests
s = requests.Session()
r = s.get("http://127.0.0.1:18080/realms/learn/protocol/openid-connect/auth", params={...})
for c in s.cookies:
c.secure = False # dev only, and only against 127.0.0.1
Subtopics
Sessions and cookies
A session is a cookie
HTTP has no memory. After a user logs in, the server has to recognise the next request as theirs, and the near-universal way is a session cookie: the server sets a random identifier in a Set-Cookie response header, the browser stores it and sends it back as a Cookie request header on every later request to that host. The identifier is the whole secret. Anyone who holds it is the user, which is why the OWASP session guidance starts with entropy (at least 64 bits of randomness), a new identifier after login (so a session fixed before authentication cannot be reused), an idle timeout and an absolute timeout, and server-side invalidation on logout.
The attributes that matter
The attributes on Set-Cookie decide where the cookie goes. Secure means the browser sends it only over HTTPS. HttpOnly means page scripts cannot read it, which takes the session out of reach of an XSS payload. SameSite (Strict, Lax or None) decides whether the cookie rides along on requests that start from another site, which is the whole SSO question and gets its own subtopic. Domain widens the cookie to subdomains (leave it off to scope it to the one host); Path narrows it. Expires or Max-Age make it persistent; without them it lives until the browser session ends. A practitioner reads these off the login response in the devtools Application tab or with curl, and a well-run app sets Secure, HttpOnly and SameSite=Lax on its session cookie and renews the identifier after login.
Cross-site redirects and SameSite
What SameSite does to a login flow
A single sign-on flow is a chain of cross-site redirects: your app sends the browser to the identity provider, the provider authenticates and sends the browser back to your app with a code (OIDC) or a signed response (SAML). Whether your app's cookies arrive on that return trip depends on SameSite. Lax, which Chrome applies by default since 2020 when a cookie sets no SameSite attribute (Firefox and Safari still treat a missing attribute as None, so set it explicitly), sends the cookie on top-level navigations that use a safe method, so a GET redirect back with a code carries it. It does not send the cookie on a cross-site POST, and the SAML HTTP-POST binding is exactly that: the provider's page auto-submits a form to your assertion consumer service. If the cookie your app uses to remember the login attempt (the OAuth state or the SAML request id) is SameSite=Lax, it is missing on the POST and the app reports 'state mismatch' or 'unsolicited response' even though the provider did everything right.
The rules of thumb
A cookie that must survive a cross-site POST callback needs SameSite=None, and the browser only honours None together with Secure, so the callback has to be HTTPS. Keep that cookie separate from the real session cookie, short-lived, and bound to the one request it tracks. The session cookie set after the callback can stay Lax. Two more browser facts a practitioner trips over: a redirect chain through more than one host counts as cross-site at each hop, and browsers treat localhost and 127.0.0.1 as secure contexts, so a Secure cookie is set and sent on plain http to 127.0.0.1 in a browser while a scripted client such as Python requests will not send it. The learning center's first scripted run against a local Keycloak hit exactly this: Keycloak sets its cookies Secure and SameSite=None, the browser logs in fine, and the script had to clear the Secure flag on its cookie jar to get past the login page.
CORS and the preflight
Origins and why the browser says no
An origin is scheme, host and port. A page served from one origin may make requests to another, but the browser enforces the Same-Origin Policy on reading the response: unless the other server says so, the page gets an opaque failure and the console shows the familiar red CORS line. Cross-Origin Resource Sharing is the server's way of saying so, with Access-Control-Allow-Origin and friends on the response. A simple request (GET, HEAD, or a POST with a form content type and no custom headers) goes out and the browser checks the headers on the way back. Anything else, such as a JSON body, an Authorization header or a PUT, triggers a preflight: an OPTIONS request with Origin, Access-Control-Request-Method and Access-Control-Request-Headers that the server has to answer with matching Allow headers, and only then does the real request go.
Where it bites an integration
A single-page app that calls the identity provider's token endpoint or your API directly from the browser needs the provider or the API to list the app's exact origin. Wildcards do not work together with credentials: when a request is sent with credentials (cookies, or HTTP authentication the browser itself holds), Access-Control-Allow-Credentials must be true and Allow-Origin must name the origin, not '*'; an Authorization header that the page's script sets itself is not a credential in this sense, but it does trigger a preflight and must be listed in Allow-Headers. Every provider has a field for this (Okta calls it Trusted Origins, Keycloak calls it Web Origins), and a missing entry shows up as a CORS error in the console: on the preflight for some providers, on the actual response for others (Keycloak answers any preflight and applies Web Origins to the real response), and in both cases before the page ever sees a token. A scripted client or a server-side backend never sees CORS because CORS is enforced by the browser alone; that difference is the fastest way to tell a CORS problem from an authentication problem. The MDN guide has the error messages; the Fetch Standard has the normative rules.
CSRF, the synchronizer token, state and RelayState
The attack and the defences
Cross-site request forgery is the browser's own helpfulness used against the user: a page on an attacker's site makes the browser send a request to your app, and because the browser attaches the session cookie automatically, the request is authenticated. The OWASP cheat sheet orders the defences: a synchronizer token (a random value the server puts in each form and checks on submit, tied to the session), the double-submit cookie when the server is stateless, SameSite cookies as a layer, and never changing state on a GET. Modern frameworks ship the synchronizer token; the engineer's job is to not turn it off for the callback route.
Why OAuth has state and SAML has RelayState
The same idea protects the login flow itself. Without a check, an attacker could start a login with their own account, capture the redirect back, and get the victim's browser to complete it, logging the victim into the attacker's account (a login CSRF). OAuth's state parameter is a random value the app generates, stores in a cookie or the session, sends to the provider, and compares when the provider sends it back; a mismatch means the response was not an answer to a request this browser started. SAML's RelayState is the same slot, and an SP that validates InResponseTo is checking the same thing on the signed message. PKCE adds a second, stronger binding of the code to the client. When an app logs 'state mismatch', the usual causes are a cookie that did not survive the redirect (see SameSite) and a user with two login tabs open.
XSS and how a token leaves a browser
The three ways a script gets in
Cross-site scripting is attacker-supplied script running in your page with your origin's privileges. Reflected XSS bounces a payload off a query parameter into the page; stored XSS saves it in data other users will see; DOM XSS is the page's own script writing untrusted data into the document. Whatever the route, the script can read anything the page can: the document, local storage, in-memory variables and any cookie that is not HttpOnly, and it can make authenticated requests from inside the page.
What that means for tokens
This is why where a token lives matters. A session cookie with HttpOnly cannot be read by injected script (though the script can still use it by making requests). An access token in local storage can be read and exfiltrated in one line, which is why the token lesson says keep tokens in memory and short-lived, and why the backend-for-frontend pattern keeps them on the server behind an HttpOnly cookie. The defence against XSS itself is output encoding by context (HTML body, attribute, JavaScript, URL), a strict Content Security Policy, and frameworks that escape by default; the OWASP XSS cheat sheet lists the encoding rules and the PortSwigger labs let you perform each kind on a deliberately vulnerable app before you review someone else's.
TLS in one page
What the handshake gives you
Every cookie, token and assertion in this module assumes the transport is private and authenticated, and that is TLS. In a TLS 1.3 handshake the client sends its supported versions and ciphers and a key share, the server answers with its choice, its certificate chain and its own key share, both sides derive the session keys, and the server proves it holds the private key for the certificate. The result is confidentiality (nobody on the path reads the token), integrity (nobody changes it) and server authentication (the client knows which server it is talking to, because a certificate authority the client trusts signed the certificate for that hostname). TLS 1.2 and 1.3 are current; anything older is a finding in a security review.
What breaks in a dev setup and at a customer
The failures a practitioner meets are about certificates, not ciphers. A self-signed certificate on a local identity provider makes every client refuse the connection until the certificate is trusted or verification is turned off for that one host (and turned back on before anything ships). A certificate for the wrong hostname fails the same way. An internal certificate authority at a customer means your container image needs their root certificate installed or every outbound call fails with a verification error. A TLS-terminating proxy or gateway means the app sees plain HTTP and has to be told it is behind TLS, or it will mark cookies insecure and build http redirect URLs that the provider rejects. Reading the TLS lines of curl -v (version, cipher, certificate subject and issuer) is the fastest check for all of these.
Sources
- guideMDN: Using HTTP cookies (developer.mozilla.org) checked 2026-10-14
- docMDN: Set-Cookie header (developer.mozilla.org) checked 2026-10-14
- guideMDN: Cross-Origin Resource Sharing (CORS) (developer.mozilla.org) checked 2026-10-14
- guideMDN: Transport Layer Security (developer.mozilla.org) checked 2026-10-14
- guideOWASP Session Management Cheat Sheet (cheatsheetseries.owasp.org) checked 2026-10-14
- guideOWASP Cross-Site Request Forgery Prevention Cheat Sheet (cheatsheetseries.owasp.org) checked 2026-10-14
- guideOWASP Cross Site Scripting Prevention Cheat Sheet (cheatsheetseries.owasp.org) checked 2026-10-14
- labPortSwigger Web Security Academy: CSRF (portswigger.net) checked 2026-10-14
- labPortSwigger Web Security Academy: Cross-site scripting (portswigger.net) checked 2026-10-14
- specRFC 6265: HTTP State Management Mechanism (datatracker.ietf.org) checked 2026-10-14
- specFetch Standard: CORS protocol (fetch.spec.whatwg.org) checked 2026-10-14
- tutorialThe Illustrated TLS 1.3 Connection (tls13.xargs.org) checked 2026-10-14