H4CK0R Network Security Β· Recon Β· Education

πŸͺ Cookies, Sessions and Authentication

Cookies, Sessions and Authentication

HTTP has no memory. Every request arrives as if the server had never met you, which means "being logged in" has to be reconstructed from something the browser sends with each request. That something is almost always a cookie, and a session cookie is a bearer credential: whoever holds it is the user, no further questions asked. Nearly every rule in this module follows from that one sentence.

What a session actually is

On successful authentication the server generates a session identifier β€” an opaque, unpredictable value from a cryptographically secure random source, 128 bits of entropy or more β€” and stores the real state (user id, roles, issue time, MFA status) server-side under that key. The identifier travels to the browser in a Set-Cookie header and comes back in a Cookie header on every request matching the cookie's scope. It should mean nothing on its own: if it decodes to user=42|role=admin, you have handed the client your authorisation state.

HTTP/1.1 200 OK
Set-Cookie: __Host-sid=Zk8x3Qv1r7Np2Lm9Td5Hb0Yc; Path=/; Secure; HttpOnly;
            SameSite=Lax; Max-Age=1800

GET /account HTTP/1.1
Cookie: __Host-sid=Zk8x3Qv1r7Np2Lm9Td5Hb0Yc

Cookie attributes in full

AttributeEffectWhat to watch
DomainOmitted: host-only, sent to the exact host that set it. Set to example.com: sent to that domain and every subdomain.Broader is strictly worse. A compromised or attacker-registered subdomain can then read and set your session cookie. Omit it unless you truly need cross-subdomain sessions.
PathCookie is sent only for matching path prefixes.Not a security boundary. Same-origin script can reach across paths, so never rely on it for isolation.
Expires / Max-AgeExpires takes an HTTP-date; Max-Age takes seconds and wins when both are present. Neither means a session cookie, dropped when the browser closes."Session cookie" is not a guarantee of deletion β€” session-restore features can preserve them across restarts.
SecureSent only over HTTPS.Prevents leakage, but cookies have no integrity from the network: an active attacker on a plain-HTTP sibling host can still set cookies that your HTTPS site will receive ("cookie tossing"). Prefixes fix that.
HttpOnlyNot readable from document.cookie.Blocks token exfiltration via XSS; does not stop XSS from acting as the user with the session attached.
SameSite=StrictNever sent on any cross-site request.A user clicking a link to you from another site arrives logged out. Strongest, worst UX; sometimes solved with a second "navigation" cookie.
SameSite=LaxSent on top-level GET navigations only β€” not on cross-site POSTs, iframes, images or fetches.The sensible default. Requires that no GET endpoint changes state.
SameSite=NoneSent on all cross-site requests. Invalid without Secure.Only for cookies that are genuinely needed in a third-party context. Combine with CSRF tokens.

Lax by default. Chromium-based browsers treat a cookie with no SameSite attribute as Lax, plus a compatibility exception that lets a top-level cross-site POST carry a cookie for roughly the first two minutes of that cookie's life. Not every browser has adopted identical defaults. So: never rely on the default or on the exception β€” set SameSite explicitly on every cookie you issue, and keep real CSRF tokens on state-changing endpoints regardless.

The __Host- and __Secure- prefixes are not naming conventions β€” browsers enforce them by refusing to store a Set-Cookie that violates the rules:

That third condition is the valuable one: a host-only cookie cannot be written by a sibling subdomain or by a network attacker on a related HTTP host, which shuts down cookie tossing and session fixation via subdomain. If you take one concrete action from this module, name your session cookie __Host-something. (SameSite and the prefixes are specified in the ongoing revision of RFC 6265 and are already implemented across mainstream browsers.)

Session fixation, and why you rotate on privilege change

The attack: the attacker makes the victim's browser hold a session identifier the attacker already knows β€” historically through a session id accepted in a URL parameter, today more often a cookie set from a subdomain the attacker controls. The victim then logs in. If the server keeps that identifier and simply attaches authenticated state to it, the attacker's copy is now an authenticated session.

// On successful authentication, in PHP
session_regenerate_id(true);   // new id, and destroy the old server-side record
$_SESSION['uid'] = $user->id;

Rotate on every change of privilege level, not only at login: after step-up MFA, after assuming an elevated role, after a password change. Never accept a session identifier from a URL or request body β€” cookie only. On logout, delete the server-side record; clearing the cookie alone leaves a valid credential in whatever log or cache captured it. Enforce an idle timeout and an absolute lifetime, keep a server-side list of active sessions per user so "sign out everywhere" is possible, and invalidate all other sessions on password change. Binding a session to a client IP or User-Agent sounds attractive and is mostly a support burden β€” mobile addresses change constantly β€” so treat a mismatch as a prompt to re-authenticate, not a hard kill.

JSON Web Tokens

A JWT (RFC 7519, signed using the JWS structure of RFC 7515) is three base64url segments separated by dots: header, payload, signature.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0IiwiZXhwIjoxNzAwMDAzNjAwfQ.<sig>

header  {"alg":"HS256","typ":"JWT"}
payload {"sub":"1234","exp":1700003600}

Both segments decode to exactly the JSON shown. That is a deliberately minimal payload; a production token normally carries more registered claims β€” iss (issuer), aud (audience), iat (issued at) and jti (a unique token identifier) β€” every one of which the verifier is expected to check rather than merely read.

base64url is encoding, not encryption: the payload is readable by anyone holding the token. Never put anything confidential in it.

The pitfalls, in order of how often they appear:

"Stateless" is the real trade-off. A signed token is valid until it expires, wherever it is presented. There is no server-side record to delete, so logging a user out, banning an account or revoking a role has no effect until expiry. Every practical answer reintroduces state: short access-token lifetimes (minutes) plus refresh tokens, a revocation list keyed on the jti claim, or a per-user "tokens issued before timestamp X are invalid" epoch checked on each request. At which point, ask what statelessness bought you.

JWTs fit short-lived service-to-service authorisation, API access tokens issued by an authorisation server, and any case where the verifier cannot reach the issuer's database. They fit poorly as a drop-in replacement for a browser session in an ordinary application: a random opaque identifier in a __Host- cookie is smaller, instantly revocable, and readable by nobody. Storing a JWT in localStorage so client-side code can read it also gives up HttpOnly, making XSS equal token theft. decode a token and read its claims β†’ Β· sign and verify one β†’

OAuth 2.0 and OpenID Connect, conceptually

OAuth 2.0 (RFC 6749) is a delegated authorisation framework: an application obtains a token to act on a user's behalf against some API, without ever seeing the user's password. It is not an authentication protocol β€” "log in with X" is OpenID Connect, a thin identity layer on top that adds an ID token (a JWT describing the authentication event) and a userinfo endpoint. Four roles: resource owner (the user), client (your app), authorisation server (issues tokens), resource server (accepts them).

The current recommendation for essentially every client type is the authorisation code flow with PKCE (Proof Key for Code Exchange, RFC 7636):

1. client: code_verifier   = 43-128 random URL-safe characters (kept locally)
           code_challenge  = BASE64URL( SHA-256( code_verifier ) )
2. browser β†’ authorisation server:
     ...&response_type=code&code_challenge=<challenge>
        &code_challenge_method=S256&state=<random>&redirect_uri=<exact>
3. authorisation server β†’ browser β†’ client:  ?code=<auth code>&state=<echo>
4. client β†’ authorisation server (back channel):
     grant_type=authorization_code&code=<auth code>&code_verifier=<verifier>
5. server checks SHA-256(verifier) == stored challenge, returns tokens

PKCE binds the authorisation code to the client instance that started the flow, so a code intercepted at the redirect β€” via an open redirect, a hijacked custom URI scheme on mobile, or a logged referrer β€” cannot be exchanged by anyone else. state is a separate control protecting the callback against CSRF, and in OIDC the nonce parameter binds the ID token to your request so it cannot be replayed.

The implicit flow (response_type=token) returned an access token directly in the URL fragment. Current IETF security guidance deprecates it: tokens in URLs end up in browser history, Referer headers and server logs, and the flow cannot authenticate the client at the token step. The resource-owner password credentials grant, where the app collects the password itself, is deprecated for the obvious reason. Register exact-match redirect URIs, never wildcards.

Password storage

If a database is stolen, the only thing between the attacker and every user's password is how expensive each guess is. General-purpose hashes are the wrong tool precisely because they are fast: MD5, SHA-1 and the SHA-2 family are built to be computed billions of times per second on commodity GPUs. You want a password hashing function with deliberately tunable cost, ideally memory-hard so custom hardware gains less advantage.

FunctionParameters to setNotes
Argon2idMemory, iterations, parallelism β€” OWASP's floor is 19 MiB of memory with 2 iterations and parallelism 1; higher memory with fewer iterations is an accepted alternative trade.Preferred choice for new systems. Tune upward to the largest cost your login latency budget allows, then measure under load.
scryptCost N, block size r, parallelism p β€” for example N=2^17, r=8, p=1.Good memory-hard alternative where Argon2 is unavailable.
bcryptWork factor (cost). OWASP's floor is 10; 12 is a common modern setting.Mature and widely available. Truncates input at 72 bytes β€” if you pre-hash long passwords, base64-encode the digest first so no null byte reaches bcrypt.
PBKDF2Iterations β€” OWASP currently lists 600,000 for PBKDF2-HMAC-SHA256.Not memory-hard; choose it mainly when FIPS validation requires it. These numbers rise over time β€” check the current cheat sheet rather than trusting a copy.
// PHP: the algorithm, salt and parameters are encoded in the stored string
$hash = password_hash($password, PASSWORD_ARGON2ID);
// $argon2id$v=19$m=65536,t=4,p=1$<salt>$<hash>

if (password_verify($password, $hash)) {
    if (password_needs_rehash($hash, PASSWORD_ARGON2ID)) {
        $hash = password_hash($password, PASSWORD_ARGON2ID);  // upgrade cost silently
    }
}

Salts are per-user random values, at least 16 bytes, stored alongside the hash β€” the functions above generate and embed them for you. They are not secret. Their job is to defeat precomputation (rainbow tables) and to stop two users with the same password producing the same hash. A single global salt does neither.

Peppers are a secret key held outside the database β€” environment variable, key manager, HSM. Apply it as an HMAC over the password before the KDF, or encrypt the resulting hash with it. The payoff is real: a SQL-injection dump with no filesystem or KMS access yields hashes that cannot be attacked at all. The cost is awkward rotation, so version your peppers and store the version with each hash. Finally, screen new passwords against known-breached corpora and follow NIST SP 800-63B β€” favour length, permit long passphrases and pasting, drop mandatory composition rules and scheduled expiry. analyse a password's strength β†’ Β· check one against breach data via k-anonymity β†’ Β· generate a strong one β†’

Multi-factor authentication

MethodHow it worksPhishing-resistant?
SMS / voice OTPCode delivered over the telephone network.No β€” and additionally exposed to SIM-swap and telecom interception. NIST SP 800-63B treats it as a restricted authenticator.
TOTP (RFC 6238, built on HOTP RFC 4226)Shared base32 secret plus a time counter, typically a 30-second step and 6 digits, run through HMAC.No β€” the user types the code into whatever page asked for it, including a real-time proxy.
Push approvalThe app shows an approve/deny prompt on an enrolled device.No β€” vulnerable to prompt-fatigue bombing; number matching helps but a relay proxy still gets an approval.
WebAuthn / passkeys (W3C Web Authentication, FIDO2)Public-key challenge–response. The signed data covers the challenge and the relying-party identity, and the browser will only ask the authenticator to sign for the origin actually being visited.Yes β€” an attacker-controlled origin cannot obtain an assertion that your server will accept.

The differentiator is not "how many factors" β€” it is whether the second factor can be relayed by a machine-in-the-middle phishing proxy. Adversary-in-the-middle kits defeat SMS, TOTP and push by design, because in each the user hands over a value the proxy replays immediately. WebAuthn breaks that: the signature is bound to the origin the browser is actually talking to, and the user has nothing to type. Where legacy factors must remain, alert on unusual authentications and treat recovery codes as credentials β€” they are stored, static, and bypass everything above.

TOTP is still a reasonable second factor and is easy to implement correctly: store the shared secret encrypted, allow a small clock-drift window (typically one step either side), reject a code already used within its window, and rate limit verification β€” six digits is a million possibilities, which is not many when guessing is free. generate and verify TOTP codes β†’

Account recovery: the usual weakest link

Recovery is the flow that exists to bypass authentication, so it inherits the security of the weakest thing it trusts. An account protected by a hardware key and a 20-character passphrase, recoverable by answering "mother's maiden name", is protected by the maiden name. Rules that hold up:

Key takeaways
  • A session cookie is a bearer credential. Opaque, 128+ bits from a CSPRNG, state kept server-side.
  • Name it __Host-something: the prefix forces Secure, Path=/ and host-only scope, which kills subdomain cookie tossing.
  • Set SameSite explicitly, add HttpOnly and Secure, and never change state on a GET.
  • Rotate the session id on login and on every privilege change; destroy the old record server-side.
  • For JWTs, the verifier picks the algorithm and key β€” never the token. Validate exp, iss and aud. Statelessness makes revocation the hard part.
  • Authorisation code flow with PKCE is the current recommendation; implicit flow is deprecated.
  • Argon2id, scrypt or bcrypt with current parameters. Never a fast hash. Per-user salts always; a pepper if you can operate one.
  • Phishing resistance is the property that matters in MFA, and WebAuthn is what provides it.
  • Recovery bypasses everything else β€” design it with the same care as login.