Updated 25 days ago | GitHub

Cookie Theft

Browser cookies are very visible and can easily be stolen or manipulated.

Some web browsers show all cookie data by looking in the preferences area. Lately, it has become more commonplace for browsers to hide this information, but that does not mean that cookie storage is less visible to an attacker. Stored cookies can also be stolen using Cross-Site Scripting (XSS).

Cookie data is also visible while in transit. It is sent in plain text in the headers of every request to the webserver and can be seen by an attacker who can observe network traffic. This is especially easy to do on an open WiFi network such as those commonly found at coffee shops and other businesses.


Imagine a website which makes the terrible security choice to store the user’s login state in a cookie as plain text.

<?php
  setcookie('user_id', 42);
  setcookie('logged_in', true);
?>

The response to the user will be in plain text.

HTTP/1.0 200 OK
Content-type: text/html
Set-Cookie: user_id=42
Set-Cookie: logged_in=true

After that every request back to the webserver will display those cookie values in plain text.

GET /any_page.php HTTP/1.1
Host: 55.66.77.88
Cookie: user_id=42; logged_in=true

If an attacker can see cookie data, then it is easy for them to “steal” it. They can forge a request and include the cookie data as if it were their own. An attacker could set their own cookies to those values or forge new requests which include “user_id=42; logged_in=true”. Alternatively, an attacker could modify the cookie values. In this example, they might try “user_id=1; logged_in=true” to see if that granted access to a different account.


Cookie Theft and Manipulation Preventions

The best prevention advice is simply not to put anything of value in cookies, where it could be intercepted. Only store non-sensitive data in cookies. For example, it is acceptable to store a user’s language preference or a user’s most recent choice for sorting a table of data. These are not sensitive, and an attacker would have little to gain from them.

Instead, it is a better practice to store sensitive information in a server-side session. A session is usually a file or database record on the server side which contains the user’s data. Sensitive data never leaves the server, so it cannot be observed in transit or in storage in the user’s browser cookies. Instead, only a reference identifier (“session ID”) is sent to the user’s browser as a cookie. As you might guess, this session ID needs to be a long and unique string to prevent random guessing. While the data is not observable in transit, it is important to note that the session ID is observable in transit, and additional precautions need to be taken.

A best practice for cookies (and session IDs stored in cookies) is to set cookie expiration dates. Do not let cookies linger, because the longer they are valid, the larger the time period they can be exploited. When a cookie is created with an expiration date, the web browser will automatically delete the cookie after that date.

It is also a good practice to set cookies with a domain and path specified. By default, a cookie is available through a primary domain (“site.com”). If a more restricted subdomain (“store.site.com”, “upload.site.com”, or “members.site.com”) or file path is specified, then the cookie will only be used for those URLs. This is an application of the Principle of Least privilege. Only give cookies meaning in the areas where they are required.

Because cookie data (and session IDs) can be stolen using Cross-Site Scripting (XSS), it is important to set cookies as being HttpOnly. This setting makes cookies unavailable to JavaScript and prevents their theft using XSS.

Cookies can also be configured as “secure cookies” or “https-only cookies” with the Secure attribute. Secure cookies can only be set over an HTTPS connection, and their data will only be sent back to the webserver over a secure connection. This prevents the cookie from being exposed if the user accidentally visits a non-HTTPS URL on the same site. Because the connection is encrypted, the cookies cannot be observed while in transit. In modern browsers Secure should be combined with SameSite (see below) rather than relied on alone.

The SameSite attribute controls whether the cookie is sent on cross-site requests, and is the browser’s first-line defense against Cross-Site Request Forgery. Use SameSite=Strict for session cookies whenever possible, or SameSite=Lax when the site needs to be reachable from cross-site top-level links. SameSite=None (which sends the cookie on every cross-site request) is only allowed in combination with Secure. Do not rely on the browser’s implicit default — different browsers have applied Lax as the default at different times, so always set SameSite explicitly. See MDN’s Set-Cookie reference for details.

For session identifiers, prefer the __Host- cookie name prefix. A cookie whose name starts with __Host- is only accepted by the browser if it is set with Secure, has no Domain attribute, and uses Path=/. This pins the cookie to the exact origin that issued it and prevents a compromised subdomain from overwriting it. OWASP’s Session Management Cheat Sheet recommends a session cookie of the form Set-Cookie: __Host-SessionID=<value>; Secure; HttpOnly; SameSite=Strict; Path=/.


In PHP, it is possible to provide only the cookie name and value, but there are additional parameters which allow setting of the expiration time, path, domain, and whether the cookie should be secure and/or httponly (true/false).

<?php
  setcookie($name, $value, $expire, $path, $domain, $secure, $httponly);
?>

For sessions, these options can be set as global default values in the php.ini file or (in PHP 7) as options when the session is started. (See cookie and session options)


Encrypt cookie data

For sensitive cookies, it is also possible to encrypt the cookie data using a two-way encryption algorithm. Not all algorithms are suitable, it must be possible to both encrypt and decrypt the values. The advantage of encrypting cookies is that if other protections fail the cookie data is never in plain text, neither in transit nor in storage.

Learn how to encrypt data with AES in PHP.


Sign cookies

Cookies can be “signed” as a protection against modification. While this could be done with any cookie value, it makes the most sense with encrypted cookie values because the original value is obscured.

The concept of “signing” a cookie is to compute a keyed message authentication code (MAC) over the cookie data and append it to the value. When the cookie comes back, the server recomputes the MAC over the received value and compares it to the attached MAC; if they match, the value has not been tampered with.

Use HMAC with a modern hash such as SHA-256 — not a bare hash with a concatenated “salt”. A bare-hash construction like sha1($value . $salt) is not a MAC: an attacker who knows the construction can append data to $value (length-extension) or shop for inputs that collide with the deprecated SHA-1 algorithm. HMAC, by contrast, is designed for this job and remains secure even when the underlying hash has weaknesses. Also compare the recomputed MAC with hash_equals() rather than ===, because PHP’s string equality short-circuits on the first differing byte and leaks the position of the mismatch through response timing — hash_equals() runs in constant time on equal-length inputs and was added specifically to defend against this class of timing attack.

The secret key should be loaded from configuration (an environment variable, a secrets manager, etc.) rather than hard-coded in source, and rotated if it is ever exposed.

An example of signing a string in PHP:

<?php
  // Load the signing key from configuration; never hard-code it in source.
  function signing_key() {
    return getenv('COOKIE_SIGNING_KEY');
  }

  function signing_mac($string) {
    return hash_hmac('sha256', $string, signing_key());
  }

  function sign_string($string) {
    return $string . '--' . signing_mac($string);
  }

  function signed_string_is_valid($signed_string) {
    $array = explode('--', $signed_string, 2);
    // if not 2 parts it is malformed or not signed
    if (count($array) !== 2) { return false; }

    $expected_mac = signing_mac($array[0]);
    // hash_equals is a constant-time comparison that prevents
    // an attacker from learning the MAC byte-by-byte via timing.
    return hash_equals($expected_mac, $array[1]);
  }
?>