Difficulty: Intermediate | Read time: 12 min | Framework: CodeIgniter 4
Why CSRF Breaks Your AJAX Forms
You build a perfectly working AJAX form. It works fine in testing. Then suddenly — "Page Expired" or a 419 error. You refresh, it works once, then breaks again.
This is CodeIgniter 4's CSRF protection doing its job — but if you don't handle it correctly in AJAX, it will drive you crazy.
In this tutorial I'll show you exactly why it happens and give you production-ready patterns to fix it permanently — for single forms, multiple forms, and even global AJAX setups.
What we'll cover: How CI4 CSRF works, why AJAX breaks it, token refresh after every request, global jQuery fix, Fetch API fix, and real-world examples from CRM and ticket system projects.
How CI4 CSRF Works (Quick Recap)
Every form submission needs a valid CSRF token. CI4 generates a token, stores it in a cookie, and expects it back in every POST request.
// app/Config/Security.php — default settings
public string $tokenName = 'csrf_test_name'; // field name
public string $headerName = 'X-CSRF-TOKEN'; // header name for AJAX
public bool $regenerate = true; // ← THIS causes most AJAX issues
public bool $redirect = true; // redirects on failure (not great for AJAX)
The problem: when $regenerate = true, the token changes after every single request. So if your AJAX doesn't update the token after each call, the second request will fail.
The 3 Most Common CSRF AJAX Mistakes
❌ Mistake 1 — Not sending the token at all
// WRONG — no CSRF token
$.post('/tickets/assign', {
ticket_id: 1,
staff_id: 5
}, function(res) { ... });
❌ Mistake 2 — Sending it once, never updating
// WRONG — token sent but never refreshed after response
$.post('/tickets/assign', {
ticket_id: 1,
staff_id: 5,
csrf_test_name: '<?= csrf_hash() ?>' // gets stale after first request
}, function(res) { ... });
❌ Mistake 3 — Wrong config for AJAX (redirect on failure)
// WRONG for AJAX — CI4 redirects instead of returning JSON error
public bool $redirect = true;
The Complete Fix — Step by Step
Step 1 — Update Security Config for AJAX
// app/Config/Security.php
public string $csrfProtection = 'cookie'; // or 'session'
public string $tokenName = 'csrf_test_name';
public string $headerName = 'X-CSRF-TOKEN';
public bool $regenerate = true; // keep true for security
public bool $redirect = false; // IMPORTANT: false for AJAX apps
public bool $samesite = 'Lax';
Setting $redirect = false means CI4 will return a proper response instead of redirecting — so your AJAX error handlers can catch it.
Step 2 — Return New Token in Every AJAX Response
In every controller method that handles AJAX, return the new token:
<?php
// app/Controllers/Tickets.php
public function assign(): \CodeIgniter\HTTP\ResponseInterface
{
if (!$this->request->isAJAX()) {
return $this->response->setStatusCode(403);
}
$ticketId = (int) $this->request->getPost('ticket_id');
$staffId = (int) $this->request->getPost('staff_id');
$assigned = $this->ticketModel->assignTicket($ticketId, $staffId);
// Always return new CSRF token in response
return $this->response->setJSON([
'success' => $assigned,
'message' => $assigned ? 'Ticket assigned!' : 'Assignment failed.',
'csrf_token' => csrf_hash(), // ← send new token back
'csrf_name' => csrf_token(), // ← send token name too
]);
}
Step 3A — jQuery Global AJAX Fix (Recommended)
Set this up once in your main layout — it handles CSRF for ALL jQuery AJAX requests automatically:
// Put this in your main layout or app.js — runs once, fixes everything
var csrfName = '<?= csrf_token() ?>';
var csrfToken = '<?= csrf_hash() ?>';
// Add CSRF to every jQuery AJAX request automatically
$.ajaxSetup({
beforeSend: function(xhr, settings) {
if (settings.type === 'POST' || settings.type === 'PUT' || settings.type === 'DELETE') {
// Add as header (works with CI4 headerName config)
xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken);
// Also add to data if it's a form POST
if (typeof settings.data === 'string') {
settings.data += '&' + csrfName + '=' + csrfToken;
} else if (typeof settings.data === 'object' && settings.data !== null) {
settings.data[csrfName] = csrfToken;
}
}
}
});
// After every AJAX response, update the token
$(document).ajaxComplete(function(event, xhr, settings) {
try {
var response = JSON.parse(xhr.responseText);
if (response.csrf_token) {
csrfToken = response.csrf_token;
csrfName = response.csrf_name || csrfName;
}
} catch(e) {
// Not a JSON response — that's fine
}
});
With this setup, you never need to manually add the token to individual AJAX calls again.
Step 3B — Individual AJAX Call Fix
If you prefer per-call control:
// Single AJAX call with CSRF — with token refresh
function assignTicket(ticketId, staffId) {
var data = {
ticket_id: ticketId,
staff_id: staffId
};
// Add CSRF token
data[csrfName] = csrfToken;
$.post('<?= base_url('tickets/assign') ?>', data, function(res) {
if (res.csrf_token) {
// Update token for next request
csrfToken = res.csrf_token;
csrfName = res.csrf_name || csrfName;
}
if (res.success) {
toastr.success(res.message);
} else {
toastr.error(res.message);
}
}).fail(function(xhr) {
if (xhr.status === 403) {
toastr.error('Session expired. Please refresh the page.');
}
});
}
Step 3C — Fetch API Fix (No jQuery)
If you're using vanilla JS or React:
// CSRF with Fetch API
let csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
let csrfName = document.querySelector('meta[name="csrf-name"]').getAttribute('content');
async function postData(url, payload) {
const body = new FormData();
body.append(csrfName, csrfToken);
Object.entries(payload).forEach(([key, value]) => {
body.append(key, value);
});
const response = await fetch(url, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest'
},
body: body
});
const data = await response.json();
// Update token from response
if (data.csrf_token) {
csrfToken = data.csrf_token;
}
return data;
}
// Usage
const result = await postData('/tickets/assign', { ticket_id: 1, staff_id: 5 });
Add these meta tags in your layout <head>:
<!-- app/Views/layouts/main.php -->
<meta name="csrf-token" content="<?= csrf_hash() ?>">
<meta name="csrf-name" content="<?= csrf_token() ?>">
Real-World Example — Lead Send Email Form
Here's a complete real-world example — a modal form that sends emails to leads with CSRF handled properly:
<!-- View: leads/send_email_modal.php -->
<div class="modal fade" id="sendEmailModal">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Send Email to Lead</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">To</label>
<input type="email" id="lead_email" class="form-control" />
</div>
<div class="mb-3">
<label class="form-label">CC</label>
<input type="text" id="lead_cc" class="form-control" placeholder="Comma separated emails" />
</div>
<div class="mb-3">
<label class="form-label">Subject</label>
<input type="text" id="email_subject" class="form-control" />
</div>
<div class="mb-3">
<label class="form-label">Message</label>
<textarea id="email_body" class="form-control" rows="5"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="sendEmailBtn">
<span class="spinner-border spinner-border-sm d-none" id="sendSpinner"></span>
Send Email
</button>
</div>
</div>
</div>
</div>
<script>
$('#sendEmailBtn').on('click', function() {
var $btn = $(this);
var $spinner = $('#sendSpinner');
// Basic validation
if (!$('#lead_email').val()) {
toastr.error('Please enter recipient email.');
return;
}
// Show loading state
$btn.prop('disabled', true);
$spinner.removeClass('d-none');
var data = {
to: $('#lead_email').val(),
cc: $('#lead_cc').val(),
subject: $('#email_subject').val(),
body: $('#email_body').val()
};
// CSRF added automatically by $.ajaxSetup (from global fix above)
$.post('<?= base_url('leads/send_email') ?>', data, function(res) {
// Update CSRF token
if (res.csrf_token) {
csrfToken = res.csrf_token;
}
if (res.success) {
toastr.success('Email sent successfully!');
$('#sendEmailModal').modal('hide');
} else {
toastr.error(res.message || 'Failed to send email.');
}
}).fail(function(xhr) {
if (xhr.status === 403) {
toastr.error('CSRF token expired. Please refresh the page.');
} else {
toastr.error('Something went wrong. Try again.');
}
}).always(function() {
$btn.prop('disabled', false);
$spinner.addClass('d-none');
});
});
</script>
What If You Want to Disable CSRF for Specific Routes?
Sometimes you need to exclude API endpoints from CSRF (like webhooks). Do it in the filter config:
// app/Config/Filters.php
public array $globals = [
'before' => [
'csrf' => ['except' => [
'api/*', // exclude all /api/ routes
'webhooks/stripe', // exclude specific webhook
]],
],
];
Common Pitfalls
⚠️ 1. Using the same token for multiple rapid requests
If you fire multiple AJAX calls in quick succession and $regenerate = true, only the first one will succeed. Solution: either set $regenerate = false (less secure) OR queue your requests so they run one at a time.
⚠️ 2. Token in URL (GET request CSRF)
Never put CSRF tokens in GET request URLs. CSRF protection is only needed for state-changing requests — POST, PUT, DELETE.
⚠️ 3. SameSite cookie issues on some browsers
If your app is embedded in an iframe or served cross-origin, set:
public string $samesite = 'None'; // requires HTTPS
⚠️ 4. AJAX response is HTML instead of JSON
If CI4 redirects instead of returning JSON, it means $redirect = true in Security.php. Change it to false for AJAX apps.
What to Build Next
- CodeIgniter 4 REST API — build and secure endpoints with token auth
- CodeIgniter 4 File Upload to AWS S3 — AJAX file upload with progress bar
- CodeIgniter 4 + jQuery AJAX CRUD — full CRUD without page reload
Conclusion
CSRF protection in CodeIgniter 4 is solid — but it requires a small amount of setup to work properly with AJAX. The global $.ajaxSetup + ajaxComplete pattern is the cleanest solution — set it up once and never worry about it again.
Follow me for more CodeIgniter production tutorials — 3 new articles every week. 🙌
Senior PHP Developer · 12+ years building production systems on CodeIgniter, Laravel & WordPress
Top comments (0)