Skip to content
← Blog Development July 29, 2026

The CSRF Token Bug That Only Broke in Safari: A Production Post-Mortem

9 min read
The CSRF Token Bug That Only Broke in Safari: A Production Post-Mortem

I deployed what I thought was airtight CSRF protection on a client project — a PHP form-heavy internal tool used by about 30 staff members — and it worked perfectly in every test I ran. Chrome, Firefox, even Edge on Windows. Then, two days after launch, a cluster of support tickets started coming in. Staff couldn't submit forms. The error message just read "Invalid request. Please try again." The bug only happened on iPhones.

That was the start of a two-day debugging session that taught me more about PHP sessions, browser cookie policies, and the gap between "tested" and "production-ready" than almost any other bug I've encountered. This is the full post-mortem: what broke, why it broke, how I found it, and how I fixed it permanently.

The Setup: What I Built

The application was a straightforward internal data entry tool. Staff logged in, filled out multi-section forms, and submitted records to a MySQL database. Nothing exotic. I'd built CSRF protection using a pattern I've used many times: generate a token on page load, store it in the PHP session, embed it in the form as a hidden field, then compare on submission.

// Token generation (on page load)
session_start();
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

// Token validation (on form submit)
session_start();
if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
    http_response_code(403);
    die('Invalid request. Please try again.');
}

Clean, simple, and it had worked on three or four previous projects without issue. The hidden field was output correctly in the HTML. I'd manually verified the token comparison logic. There was no obvious flaw.

Reproducing the Bug

The first thing I did was grab an iPhone and try to reproduce it myself. I could — every single form submission on Safari for iOS returned the 403. Same form, same network, opened on Chrome for iOS: it worked. That immediately told me this was browser-specific, not a server configuration issue at the routing level.

My first instinct was to check whether the session was being maintained between the GET request (page load) and the POST request (form submit). I added a temporary debug block at the top of the form processing script:

session_start();
error_log('Session ID: ' . session_id(), 3, '/home/myaccount/logs/debug.log');
error_log('Session token: ' . ($_SESSION['csrf_token'] ?? 'NOT SET'), 3, '/home/myaccount/logs/debug.log');
error_log('POST token: ' . ($_POST['csrf_token'] ?? 'NOT SET'), 3, '/home/myaccount/logs/debug.log');

I tailed the log while submitting from Safari on iOS. What I found was disturbing: the session ID on the POST request was completely different from the one on the GET request. A new session was being created for every form submission. That's why $_SESSION['csrf_token'] was always empty on the validation side — it was a brand new session with no token set.

Safari's Intelligent Tracking Prevention (ITP) has been getting stricter since around 2017, and by the time I was building this project, it was blocking session cookies it classified as being set in a cross-origin or "tracking" context. In my case, the application was hosted on a subdomain of the client's main site — something like app.clientdomain.com. The main site was served from clientdomain.com.

The session cookie was being set without an explicit SameSite attribute. Different PHP versions and server configurations handle the default differently. On this particular shared cPanel host — running PHP 7.4 — the default was effectively SameSite=None without the Secure flag. Safari treats cookies with SameSite=None but without Secure as invalid and silently drops them. Chrome is more permissive and just sets them anyway.

So the flow was: Safari loads the form page ? PHP sets a session cookie that Safari rejects ? user submits the form ? PHP starts a new session because there's no valid session cookie ? CSRF token comparison fails because the new session has no token.

The fix on the cookie settings side was to explicitly declare the session cookie parameters before calling session_start():

session_set_cookie_params([
    'lifetime' => 0,
    'path'     => '/',
    'domain'   => '.clientdomain.com',
    'secure'   => true,
    'httponly' => true,
    'samesite' => 'Lax'
]);
session_start();

Setting samesite to Lax instead of None was the key move. Lax allows the cookie to be sent on top-level navigations and same-site requests, which covers normal form submissions. It doesn't require the Secure flag to be valid, but I set it anyway because the site was already on HTTPS — and honestly, if you're not running HTTPS in production in the year we're in now, that's a separate conversation entirely.

The domain value with the leading dot is intentional. It makes the cookie valid across all subdomains under clientdomain.com, which is what you want when your app sits on a subdomain but might share authentication context with the root domain. (I wrote about session and cookie configuration in more depth in 14 Session and Authentication Patterns I Wish I'd Known Earlier in PHP — that post covers why explicit cookie parameters beat relying on php.ini defaults.)

Why This Passed All My Tests

After fixing it, I had to sit with the uncomfortable question: how did I miss this? A few honest answers:

I tested on Chrome and Firefox on desktop. I didn't have a systematic mobile browser testing checklist. Safari on iOS behaves meaningfully differently from Safari on macOS for some of these cookie rules, and it's not something you'd catch by just opening DevTools on your Mac.

I was relying on default PHP session configuration. When a pattern works across several projects, you stop questioning it. The shared cPanel hosts for those earlier projects happened to have PHP configurations that set session cookies in a way Safari didn't reject. It was luck, not design.

I didn't verify the actual cookie being set in the browser. If I'd opened Safari's developer tools and checked Application ? Storage ? Cookies on the test subdomain, I would have seen the session cookie wasn't persisting between page loads. That's a five-second check I skipped.

The Secondary Fix: Token Regeneration After Login

While I was in the code, I noticed a second issue that the bug had exposed. My CSRF token was generated once per session and reused for every form. That's acceptable for many cases, but for this application — where multiple forms could be open in different tabs — I decided to move to a per-form token strategy, generating a new token each time a form page loads rather than once at session start.

// Generate a fresh token for this specific form load
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
$_SESSION['csrf_token_time'] = time();

I also added a time-based expiry check on validation, rejecting tokens older than 30 minutes:

$token_age = time() - ($_SESSION['csrf_token_time'] ?? 0);
if ($token_age > 1800) {
    // Token expired
    http_response_code(403);
    die('Session expired. Please refresh and try again.');
}

This isn't necessary for every project, but for a form where someone might open the page, walk away for 45 minutes, and come back to submit — it adds a layer of protection against replay attacks using stolen tokens from old sessions.

What I Do Differently Now

The main process changes I made after this:

  • Always set session cookie parameters explicitly. Never rely on php.ini or cPanel defaults. The four lines of session_set_cookie_params() take 30 seconds and remove a whole category of environment-specific bugs.
  • Test on Safari for iOS before every launch. Even if the client hasn't mentioned iPhone users. Use BrowserStack or a real device — the iOS Simulator in Xcode gets you pretty close for cookie behaviour but isn't identical.
  • Log both sides of a token comparison on failure. During development and staging, I now log the session ID, the expected token, and the submitted token whenever validation fails. I remove or gate those logs behind an environment flag before production.
  • Set Secure and HttpOnly as non-negotiable defaults. If the site isn't on HTTPS, that problem needs solving first. HTTPS is free, cPanel makes it one click with Let's Encrypt, and there's no reasonable tradeoff where HTTP in production is acceptable.
  • Keep a per-environment checklist for CSRF verification. I now have a literal five-item checklist I run through before deploying any form-heavy feature: cookie params set explicitly, token in hidden field, comparison uses hash_equals(), failure returns 403 not 200, tested on Chrome + Safari on mobile.

The Broader Lesson

What made this bug particularly frustrating is that the CSRF implementation itself was correct. The logic was sound. The failure was entirely in the environment assumptions baked into my defaults. I'd assumed PHP's session cookie handling was consistent across environments, and I'd assumed Chrome-passing meant browser-passing.

That's a pattern I keep running into: the bug isn't in the feature you built, it's in the infrastructure you didn't explicitly configure. Security features are especially vulnerable to this because they often fail silently in ways that look like user error rather than code error — staff in this case assumed they were "doing something wrong" with the form before anyone escalated it as a bug.

If your application has any forms and you haven't explicitly looked at your session cookie configuration on your actual production environment, check it today. Open PHP info, look at session.cookie_samesite and session.cookie_secure, and ask yourself whether you set those values or whether you're relying on defaults you've never verified. Chances are the answer is the latter — and in Safari, that gap has consequences.


Frequently Asked Questions

Why would a CSRF token validation fail only in Safari and not in Chrome or Firefox?

Safari has stricter third-party cookie policies and ITP (Intelligent Tracking Prevention) rules that can prevent session cookies from being sent in certain cross-origin contexts. If your CSRF token is tied to a PHP session and that session cookie gets blocked, every token comparison will fail silently.

Is storing a CSRF token in a session the right approach for PHP applications?

It is a solid and widely used approach, but you need to ensure the session is reliably started before token generation and that your cookie settings — SameSite, Secure, HttpOnly — are explicitly set rather than left to PHP defaults, which vary by server configuration.

How do you debug CSRF failures on a shared cPanel hosting environment where you have limited server access?

Write failed token comparisons to a custom error log using error_log() with a file path you control, and log both the expected and received token values alongside the session ID so you can trace exactly where the mismatch originates.

Share Twitter / X LinkedIn

Enjoyed this? Let's build something.

Start a project →
Keep reading

More articles

14 Session and Authentication Patterns I Wish I'd Known Earlier in PHP
Development July 18, 2026
14 Session and Authentication Patterns I Wish I'd Known Earlier in PHP
PHP sessions look simple until they betray you in production. Here are 14 patterns I've learned the hard way building real authentication systems.
Read →
GSAP ScrollTrigger Is Overkill for 80% of What You're Building — Here's What to Use Instead
Development June 6, 2026
GSAP ScrollTrigger Is Overkill for 80% of What You're Building — Here's What to Use Instead
GSAP ScrollTrigger is a powerful tool, but reaching for it by default has real costs in complexity, bundle weight, and maintainability. After shipping dozens of animated sites, I've learned when native CSS and the Intersection Observer API are genuinely better choices.
Read →
Before & After: How MySQL Indexing Turned a 4-Second Query Into 40 Milliseconds
Development May 28, 2026
Before & After: How MySQL Indexing Turned a 4-Second Query Into 40 Milliseconds
A slow dashboard nearly killed a client project — until a few well-placed indexes changed everything. Here's the exact before-and-after breakdown of what I found, what I fixed, and why it worked.
Read →