— Article — № 021

021 —Drupal

Drupal session token leaks: three patterns and the fixes

An admin getting silently logged out is rarely a flaky cookie. On legacy Drupal sites it usually means the session token is being copied out of a referrer log or a cached page.

Vintage rotary phone receiver on worn leather blotter, coiled cable across dark oak desk, red wax seal on folded paper.
Hero · staged still№ 021

The first hint was a support ticket at 02:18: an admin user on a 14-year-old Drupal 7 site kept getting logged out, but the watchdog table told a different story. Someone in another country was opening her session, idling for forty seconds, then closing the tab. Her session token was being copy-pasted, almost certainly out of a referrer log somewhere upstream.

Session token leaks on legacy Drupal sites rarely look like a single dramatic breach. They look like users complaining about flaky logins. By the time you trace it back, the token has been visible to a CDN, an analytics provider, three logged proxies and a developer's browser history. Below are the three leak paths we see most often on these stacks, with the fixes you can ship before lunch.

1. Session IDs travelling in the URL

This one predates a lot of modern hardening and still shows up on Drupal 6 and 7 installs that were copied from server to server over the years. If PHP's session.use_trans_sid is on, or if session.use_only_cookies is off, PHP will happily append ?PHPSESSID=... to internal links the first time a visitor lands on the site without a cookie. That string is now in:

  • the browser's address bar
  • every Referer header sent to fonts.googleapis.com, the CDN, any analytics pixel
  • access logs on every proxy in between
  • the visitor's history sync to their other devices

To check what your stack is doing, run on the server:

php -r 'echo "use_trans_sid=".ini_get("session.use_trans_sid").PHP_EOL;
echo "use_only_cookies=".ini_get("session.use_only_cookies").PHP_EOL;'

If use_trans_sid is anything other than 0, you have the problem. Force the correct defaults at the Drupal level in sites/default/settings.php so the fix survives PHP upgrades:

ini_set('session.use_trans_sid', 0);
ini_set('session.use_only_cookies', 1);
ini_set('session.use_strict_mode', 1);

use_strict_mode is the underrated one. It tells PHP to refuse a session ID it did not issue itself, which kills the classic "paste the cookie into a fresh browser" attack. The PHP manual covers the full list of session security ini settings and is worth a read end to end.

Then grep your codebase for session_id( and the SID constant in custom modules. We have found internal "magic links" in client code that literally email customers a URL containing their own session ID. Replace those with one-time tokens via drupal_get_token() or a row in the flood table.

2. Cookies without Secure, HttpOnly or SameSite

Drupal sets a session cookie called SESS<hash> on HTTP and SSESS<hash> on HTTPS. On a healthy site you want it flagged Secure, HttpOnly and SameSite=Lax. On a site that was built when the front end was http://, then bolted to TLS five years later via a reverse proxy, you often get none of the three.

Confirm what you are actually serving:

curl -sI https://example.com/user/login | grep -i set-cookie

If the response does not contain Secure; HttpOnly; SameSite=Lax, fix it in settings.php:

ini_set('session.cookie_secure', 1);
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_samesite', 'Lax');
$cookie_domain = '.example.com';

On Drupal 7 you also want $conf['https'] = TRUE; if the site sits behind a TLS-terminating proxy, otherwise Drupal will keep issuing the insecure SESS cookie and accept it back over HTTP. The OWASP write-up on the HttpOnly flag explains why this single attribute blocks most XSS-driven session token theft, and it costs you nothing to ship.

SameSite is the trickier one. Lax is safe for almost every Drupal install. Strict breaks federated login flows. None requires Secure and reopens cross-site territory you almost certainly do not want. If a payment provider's callback breaks after you set Lax, your real bug is that the callback is a GET, not the cookie policy.

3. Authenticated pages served from the page cache

This one is the most embarrassing, because the leak is usually self-inflicted. Drupal's internal page cache, plus a Varnish layer in front, plus a CDN at the edge, is fine for anonymous traffic. The moment one of those layers caches a response that contained a logged-in user's CSRF token, form build ID or username in the markup, every subsequent visitor gets that token.

A few signs you have this problem:

  • a Cache-Control: public, max-age=... header on a page that includes a <form> with a form_token hidden input
  • Set-Cookie: SESS... appearing on the same response as X-Drupal-Cache: HIT
  • two anonymous users seeing the exact same form_build_id when you reload from incognito

The fix is to make the cacheability of authenticated responses explicit. In .htaccess, or in your Varnish VCL, never cache when a session cookie is present:

# .htaccess, before the Drupal rewrite block
SetEnvIf Cookie "S?SESS[a-f0-9]+=" has_session
Header always set Cache-Control "private, no-store, no-cache, must-revalidate" env=has_session
Header always unset Set-Cookie env=has_session

In Drupal 7, audit anything that calls drupal_page_is_cacheable(). In Drupal 8 and later, the cache contexts and tags system does this work for you, but only if your custom blocks declare the right contexts. A block that renders user-specific content without cache.contexts: ['user'] will get baked into the anonymous variant of the page and served to the world.

While you are in there, run this against the database to see how many active sessions you have per uid. More than a handful for the same user, on different IP prefixes, is a strong tell:

SELECT uid,
       COUNT(*) AS sessions,
       COUNT(DISTINCT SUBSTRING_INDEX(hostname, '.', 2)) AS networks
FROM sessions
WHERE uid > 0
GROUP BY uid
HAVING networks > 2
ORDER BY networks DESC;

If the same uid is logged in from five different /16 networks, the cookie is being shared, not the password.

A note on tooling

When we built Pier we ran into this exact pattern on a client's Drupal 7 install, and the way we ended up handling it was to wire a small audit straight into the chat: an operator can ask the MySQL editor to list sessions with more than two distinct networks, then patch settings.php and push it back over SFTP without leaving the app. The version history sits one click away, which matters the first time you set SameSite=Lax in production and a niche flow breaks.

Open sites/default/settings.php, paste the three ini_set lines from section two, deploy, and re-run curl -sI against your login page. If the Set-Cookie response now reads Secure; HttpOnly; SameSite=Lax, you have closed the largest of the three leaks before lunch. The other two can wait for the next sprint.

— Questions —

Does setting SameSite=Lax break payment provider callbacks?

Only if the callback is a GET that needs to carry the session cookie. POST callbacks work fine under Lax. Convert the GET callback to POST, or hand the provider a one-time token instead of relying on the session.

Is session.use_strict_mode safe to enable on a production Drupal site?

Yes for almost every install. It only rejects session IDs PHP did not issue. The one thing it breaks is custom code that hands a client a session ID to log them in later, which you should not be doing.

Why does Drupal still issue an insecure cookie after I enabled HTTPS?

Drupal 7 only flips to the SSESS cookie when it sees the request is HTTPS. Behind a TLS-terminating proxy you also need $conf['https'] = TRUE; in settings.php, otherwise it falls back to SESS.

How do I confirm the page cache is not serving authenticated content?

Hit a logged-in page in two different incognito windows and compare the form_build_id values. If they match, the page is being cached with user-specific markup baked in.