Cloudflare JS Challenge Is Breaking My SPA: Laravel Fix
By the end of this, your Laravel SPA will handle Cloudflare challenges instead of crashing on them, and the fix takes about 30 minutes. If your bug report says "a Cloudflare JS challenge is breaking my SPA", the problem isn't in your Vue or Laravel code.
What changed is how Cloudflare describes and handles these challenges. Its docs now list the old JS Challenge as a Non-Interactive Challenge and point SPA owners to Turnstile pre-clearance for fetch and XHR calls. I couldn't confirm an exact date for either change, so go by the labels your dashboard shows.
Short answer: Cloudflare challenges are HTML pages that need a full browser navigation to run. When one lands on an axios or fetch call, your SPA gets a 403 with a cf-mitigated: challenge header and HTML instead of JSON. Fix it by excluding API routes from challenge rules, reloading the page when that header appears, or using Turnstile pre-clearance.
Why the Cloudflare JS challenge is breaking my SPA
I'll use one example throughout. You have a Laravel + Vue storefront at shop.example.com. The product list loads from /api/products with axios, and you have a WAF custom rule that challenges traffic from outside your main market.
Here's the sequence that breaks it:
- A visitor opens
shop.example.com. That's a normal page load, so the browser shows the challenge, passes it, and Cloudflare sets acf_clearancecookie. - The SPA works. Every axios call sends the cookie and gets through.
- The cookie expires (its lifetime is the zone's Challenge Passage setting), or a rule matches the API path more strictly than the page.
- The next call to
/api/productsgets challenged. Cloudflare returns an HTML challenge page with a 403.
A browser can run a challenge page when it navigates to it. It can't run one that arrives as the body of an XHR or fetch response. Your JavaScript just receives a string of HTML it has no use for.
That's also why it works fine on your machine. There's no Cloudflare in front of php artisan serve.
What this means for PHP/web developers
The first thing to accept: Laravel never sees these requests. Cloudflare answers them at the edge. Nothing shows up in storage/logs/laravel.log, and no middleware or exception handler can catch them. The fix lives in Cloudflare's settings or in your frontend.
Reproduce the 403 cf-mitigated: challenge response
You don't need to wait for a real visitor to hit it.
- In Cloudflare, add a temporary custom rule with action Managed Challenge and this expression (use your own IP):
(ip.src eq 203.0.113.10 and starts_with(http.request.uri.path, "/api/"))
- Open the site, then delete the
cf_clearancecookie in DevTools (Application tab, Cookies). - Trigger an API call and open the request in the Network tab.
You should see:
HTTP/2 403
content-type: text/html; charset=UTF-8
cf-mitigated: challenge
server: cloudflare
cf-mitigated: challenge is the header Cloudflare documents for detecting challenged responses in fetch and XHR calls. It's what your fix will check for.
Why you see "Unexpected token '<'" instead of a challenge
If you use fetch() and call response.json() without checking the status, the console shows:
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
With axios you usually get a rejected promise with status: 403 and an HTML string in error.response.data. Different symptom, same cause.
The cross-origin variation
If your API lives on another hostname, say api.example.com, you may only see a CORS error. As far as I can tell, that's because the challenge response comes from Cloudflare, not Laravel, so the headers from your config/cors.php aren't on it. The browser blocks the response before your code can read cf-mitigated. Same-origin APIs avoid this, which is one more reason Sanctum's SPA setup keeps things on one domain.
The Inertia variation
Inertia apps get hit on regular page routes, not just /api. After the first load, every Inertia visit is an XHR. A challenged visit shows the challenge HTML in Inertia's modal for non-Inertia responses, or just fails. Excluding /api/ from your rule won't help here.
Should you act now? Fixes in the order I'd try them
Check Security > Events in Cloudflare (the menu name may differ on your plan) and filter by your API path. If you see challenge actions on /api/ or on Inertia routes, act now. If you don't, I'd still add Fix 2. It's a few lines and stops a confusing failure later.
Fix 1: Stop challenging your API routes
This is the standard approach. Challenge HTML page loads, where a browser can actually solve the challenge, and protect the API another way (Sanctum auth, Laravel's throttle middleware, Cloudflare rate limiting).
Edit the challenge rule and add a path exclusion. In our example the rule was:
(ip.src.country ne "SA" and not cf.client.bot)
Change it to:
(ip.src.country ne "SA" and not cf.client.bot and not starts_with(http.request.uri.path, "/api/") and http.request.uri.path ne "/sanctum/csrf-cookie")
If you're on Inertia, a path exclusion won't cover the visits, so go straight to Fix 2.
If the challenge comes from Bot Fight Mode rather than your own rule, a custom rule can't skip it, according to Cloudflare's docs. Your choices there are turning Bot Fight Mode off or moving to a plan with configurable bot rules.
Fix 2: Catch the challenge in axios and reload
When a challenge lands on an API call, the simplest recovery is a full page reload. The browser then gets the challenge as a page, solves it, gets a fresh cf_clearance, and the SPA starts working again.
In resources/js/bootstrap.js:
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
const isChallenge = (response) =>
response?.status === 403 && response.headers?.['cf-mitigated'] === 'challenge';
window.axios.interceptors.response.use(
(response) => response,
(error) => {
if (isChallenge(error.response)) {
let last = 0;
try { last = Number(sessionStorage.getItem('cfReloadAt') || 0); } catch (e) {}
// Reload at most once every 30 seconds to avoid a reload loop
if (Date.now() - last > 30000) {
try { sessionStorage.setItem('cfReloadAt', String(Date.now())); } catch (e) {}
window.location.reload();
return new Promise(() => {}); // stop the chain while the page reloads
}
}
return Promise.reject(error);
}
);
For Inertia, use its invalid event, which fires on non-Inertia responses:
import { router } from '@inertiajs/vue3';
router.on('invalid', (event) => {
const res = event.detail.response;
if (res?.status === 403 && res.headers?.['cf-mitigated'] === 'challenge') {
event.preventDefault();
window.location.reload();
}
});
The shape of event.detail.response can differ between Inertia versions, so log it once and check before you rely on it.
The gotcha: a reload only helps if the page URL itself is covered by a challenge of the same level or higher. If your rule challenges /api/* but not the page, the reload passes without a challenge, no new cookie is issued, and the next API call fails again. That's what the 30 second guard is for. If you hit it, fix the rule so it covers pages, or use Fix 3. Also warn your users: a reload loses unsaved form input.
Fix 3: Use Turnstile pre-clearance
Pre-clearance lets a Turnstile widget on your page issue the cf_clearance cookie, so the SPA can get cleared without a reload. You enable it on the widget in the Turnstile dashboard and pick a clearance level. That level must match or exceed your rule: per Cloudflare's docs, a Managed clearance passes Managed and Non-Interactive challenges, but not Interactive ones. The widget's hostname must also belong to the same zone.
Load the script in your Blade layout and add a container:
<div id="cf-turnstile"></div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
Then retry the request after the widget succeeds:
const getClearance = () =>
new Promise((resolve, reject) => {
window.turnstile.render('#cf-turnstile', {
sitekey: import.meta.env.VITE_TURNSTILE_SITE_KEY,
callback: (token) => resolve(token),
'error-callback': () => reject(new Error('Turnstile failed')),
});
});
window.axios.interceptors.response.use(
(response) => response,
async (error) => {
const config = error.config;
if (isChallenge(error.response) && !config._cfRetried) {
config._cfRetried = true;
await getClearance(); // sets cf_clearance on success
return window.axios(config); // replay the original request
}
return Promise.reject(error);
}
);
Use this instead of the reload interceptor from Fix 2, not alongside it. Cloudflare's docs also say to validate the Turnstile token on your server. If you've done this for reCAPTCHA, it'll look familiar (the token-expiry issues in this reCAPTCHA v3 timeout fix for PHP forms work the same way):
use Illuminate\Support\Facades\Http;
$result = Http::asForm()->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'secret' => config('services.turnstile.secret'),
'response' => $request->input('cf_turnstile_token'),
'remoteip' => $request->ip(),
])->json();
abort_unless($result['success'] ?? false, 422, 'Bot check failed.');
What doesn't work
- Sending
Accept: application/json. Cloudflare decides before Laravel sees the request; your headers don't change the challenge. - Raising Challenge Passage. It only delays the next failure.
- Skipping the rule when
X-Requested-Withis present. Anyone can send that header. In my opinion that's as good as having no rule. - Catching it in Laravel's exception handler. The request never reached PHP.
What to watch
Cloudflare renames and reorganizes its security products fairly often. "JS Challenge", "Non-Interactive Challenge" and "Managed Challenge" may show up differently in your dashboard than in older answers online, so check which action your rule actually uses.
Keep an eye on Bot Fight Mode and similar zone-wide toggles. They can start challenging API traffic without anyone adding a rule. I'd also send a small event to your error tracker whenever the interceptor sees cf-mitigated, so you find out from your logs, not your customers.
If your product list was built the way retrieving data from the database with Vue in Laravel describes, every component that calls axios gets this protection once the interceptor is in bootstrap.js.
If you'd like someone to look at your Cloudflare rules and Laravel setup with you, contact HeadsNTail.
FAQ
Why does my Laravel API return 403 only behind Cloudflare?
Cloudflare is answering the request, not Laravel. Look for server: cloudflare and cf-mitigated: challenge in the response headers. If they're there, the 403 came from a challenge or a bot setting, and your Laravel logs will be empty.
Can a Cloudflare challenge be solved inside an AJAX request?
No. A challenge is an HTML page that has to render and run in the browser, and an XHR or fetch call can't do that. You need a full page load or Turnstile pre-clearance.
Why does it work locally but fail in production?
Your local XAMPP or php artisan serve setup has no Cloudflare in front of it, so there's nothing to challenge requests. Test against the live domain, or a staging hostname proxied through Cloudflare.
How long does the cf_clearance cookie last?
It lasts for the Challenge Passage duration configured on your zone. When it expires, the next challenged request fails, which is why SPAs often break after the tab has been open a while.
Should I turn off Bot Fight Mode for my API?
If it's what challenges your API calls and you can't exclude paths from it, yes, or move to a plan where bot rules are configurable. Protect the API with authentication and rate limiting instead.
Next time someone tells you "the Cloudflare JS challenge is breaking my SPA", open the Network tab, look for cf-mitigated: challenge, and start with Fix 1 or Fix 2.


