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)
Enter fullscreen mode Exit fullscreen mode
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) { ... });
Enter fullscreen mode Exit fullscreen mode
❌ 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) { ... });
Enter fullscreen mode Exit fullscreen mode
❌ Mistake 3 — Wrong config for AJAX (redirect on failure)
// WRONG for AJAX — CI4 redirects instead of returning JSON error
public bool $redirect = true;
Enter fullscreen mode Exit fullscreen mode
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';
Enter fullscreen mode Exit fullscreen mode
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
]);
}
Enter fullscreen mode Exit fullscreen mode
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
}
});
Enter fullscreen mode Exit fullscreen mode
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.');
}
});
}
Enter fullscreen mode Exit fullscreen mode
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 });
Enter fullscreen mode Exit fullscreen mode
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() ?>">
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode
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
]],
],
];
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
⚠️ 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
답글 남기기