{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreifhiessxhujqi7demvnvepvzzp5rn5uz4y75d5yzltdsx3uottxw4",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mpfsfbx53y32"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreignl5xsg2dvfthg5ioi2zdjxq6z7t7ol5rlndtttuedhidw4ixh4i"
},
"mimeType": "image/webp",
"size": 69190
},
"path": "/sunakshi_thakur_84a5f59fd/codeigniter-4-csrf-protection-in-ajax-forms-the-complete-fix-5b18",
"publishedAt": "2026-06-29T05:09:54.000Z",
"site": "https://dev.to",
"tags": [
"php",
"codeigniter",
"security",
"javascript"
],
"textContent": "**Difficulty:** Intermediate | **Read time:** 12 min | **Framework:** CodeIgniter 4\n\n## Why CSRF Breaks Your AJAX Forms\n\nYou 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.\n\nThis is CodeIgniter 4's CSRF protection doing its job — but if you don't handle it correctly in AJAX, it will drive you crazy.\n\nIn 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.\n\n> **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.\n\n## How CI4 CSRF Works (Quick Recap)\n\nEvery form submission needs a valid CSRF token. CI4 generates a token, stores it in a cookie, and expects it back in every POST request.\n\n\n\n // app/Config/Security.php — default settings\n public string $tokenName = 'csrf_test_name'; // field name\n public string $headerName = 'X-CSRF-TOKEN'; // header name for AJAX\n public bool $regenerate = true; // ← THIS causes most AJAX issues\n public bool $redirect = true; // redirects on failure (not great for AJAX)\n\n\nThe 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.\n\n## The 3 Most Common CSRF AJAX Mistakes\n\n### ❌ Mistake 1 — Not sending the token at all\n\n\n // WRONG — no CSRF token\n $.post('/tickets/assign', {\n ticket_id: 1,\n staff_id: 5\n }, function(res) { ... });\n\n\n### ❌ Mistake 2 — Sending it once, never updating\n\n\n // WRONG — token sent but never refreshed after response\n $.post('/tickets/assign', {\n ticket_id: 1,\n staff_id: 5,\n csrf_test_name: '<?= csrf_hash() ?>' // gets stale after first request\n }, function(res) { ... });\n\n\n### ❌ Mistake 3 — Wrong config for AJAX (redirect on failure)\n\n\n // WRONG for AJAX — CI4 redirects instead of returning JSON error\n public bool $redirect = true;\n\n\n## The Complete Fix — Step by Step\n\n### Step 1 — Update Security Config for AJAX\n\n\n // app/Config/Security.php\n public string $csrfProtection = 'cookie'; // or 'session'\n public string $tokenName = 'csrf_test_name';\n public string $headerName = 'X-CSRF-TOKEN';\n public bool $regenerate = true; // keep true for security\n public bool $redirect = false; // IMPORTANT: false for AJAX apps\n public bool $samesite = 'Lax';\n\n\nSetting `$redirect = false` means CI4 will return a proper response instead of redirecting — so your AJAX error handlers can catch it.\n\n### Step 2 — Return New Token in Every AJAX Response\n\nIn every controller method that handles AJAX, return the new token:\n\n\n\n <?php\n // app/Controllers/Tickets.php\n\n public function assign(): \\CodeIgniter\\HTTP\\ResponseInterface\n {\n if (!$this->request->isAJAX()) {\n return $this->response->setStatusCode(403);\n }\n\n $ticketId = (int) $this->request->getPost('ticket_id');\n $staffId = (int) $this->request->getPost('staff_id');\n\n $assigned = $this->ticketModel->assignTicket($ticketId, $staffId);\n\n // Always return new CSRF token in response\n return $this->response->setJSON([\n 'success' => $assigned,\n 'message' => $assigned ? 'Ticket assigned!' : 'Assignment failed.',\n 'csrf_token' => csrf_hash(), // ← send new token back\n 'csrf_name' => csrf_token(), // ← send token name too\n ]);\n }\n\n\n### Step 3A — jQuery Global AJAX Fix (Recommended)\n\nSet this up once in your main layout — it handles CSRF for ALL jQuery AJAX requests automatically:\n\n\n\n // Put this in your main layout or app.js — runs once, fixes everything\n\n var csrfName = '<?= csrf_token() ?>';\n var csrfToken = '<?= csrf_hash() ?>';\n\n // Add CSRF to every jQuery AJAX request automatically\n $.ajaxSetup({\n beforeSend: function(xhr, settings) {\n if (settings.type === 'POST' || settings.type === 'PUT' || settings.type === 'DELETE') {\n // Add as header (works with CI4 headerName config)\n xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken);\n\n // Also add to data if it's a form POST\n if (typeof settings.data === 'string') {\n settings.data += '&' + csrfName + '=' + csrfToken;\n } else if (typeof settings.data === 'object' && settings.data !== null) {\n settings.data[csrfName] = csrfToken;\n }\n }\n }\n });\n\n // After every AJAX response, update the token\n $(document).ajaxComplete(function(event, xhr, settings) {\n try {\n var response = JSON.parse(xhr.responseText);\n if (response.csrf_token) {\n csrfToken = response.csrf_token;\n csrfName = response.csrf_name || csrfName;\n }\n } catch(e) {\n // Not a JSON response — that's fine\n }\n });\n\n\nWith this setup, you **never need to manually add the token** to individual AJAX calls again.\n\n### Step 3B — Individual AJAX Call Fix\n\nIf you prefer per-call control:\n\n\n\n // Single AJAX call with CSRF — with token refresh\n function assignTicket(ticketId, staffId) {\n var data = {\n ticket_id: ticketId,\n staff_id: staffId\n };\n\n // Add CSRF token\n data[csrfName] = csrfToken;\n\n $.post('<?= base_url('tickets/assign') ?>', data, function(res) {\n if (res.csrf_token) {\n // Update token for next request\n csrfToken = res.csrf_token;\n csrfName = res.csrf_name || csrfName;\n }\n\n if (res.success) {\n toastr.success(res.message);\n } else {\n toastr.error(res.message);\n }\n }).fail(function(xhr) {\n if (xhr.status === 403) {\n toastr.error('Session expired. Please refresh the page.');\n }\n });\n }\n\n\n### Step 3C — Fetch API Fix (No jQuery)\n\nIf you're using vanilla JS or React:\n\n\n\n // CSRF with Fetch API\n let csrfToken = document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content');\n let csrfName = document.querySelector('meta[name=\"csrf-name\"]').getAttribute('content');\n\n async function postData(url, payload) {\n const body = new FormData();\n body.append(csrfName, csrfToken);\n\n Object.entries(payload).forEach(([key, value]) => {\n body.append(key, value);\n });\n\n const response = await fetch(url, {\n method: 'POST',\n headers: {\n 'X-CSRF-TOKEN': csrfToken,\n 'X-Requested-With': 'XMLHttpRequest'\n },\n body: body\n });\n\n const data = await response.json();\n\n // Update token from response\n if (data.csrf_token) {\n csrfToken = data.csrf_token;\n }\n\n return data;\n }\n\n // Usage\n const result = await postData('/tickets/assign', { ticket_id: 1, staff_id: 5 });\n\n\nAdd these meta tags in your layout `<head>`:\n\n\n\n <!-- app/Views/layouts/main.php -->\n <meta name=\"csrf-token\" content=\"<?= csrf_hash() ?>\">\n <meta name=\"csrf-name\" content=\"<?= csrf_token() ?>\">\n\n\n## Real-World Example — Lead Send Email Form\n\nHere's a complete real-world example — a modal form that sends emails to leads with CSRF handled properly:\n\n\n\n <!-- View: leads/send_email_modal.php -->\n <div class=\"modal fade\" id=\"sendEmailModal\">\n <div class=\"modal-dialog modal-lg\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h5 class=\"modal-title\">Send Email to Lead</h5>\n <button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button>\n </div>\n <div class=\"modal-body\">\n <div class=\"mb-3\">\n <label class=\"form-label\">To</label>\n <input type=\"email\" id=\"lead_email\" class=\"form-control\" />\n </div>\n <div class=\"mb-3\">\n <label class=\"form-label\">CC</label>\n <input type=\"text\" id=\"lead_cc\" class=\"form-control\" placeholder=\"Comma separated emails\" />\n </div>\n <div class=\"mb-3\">\n <label class=\"form-label\">Subject</label>\n <input type=\"text\" id=\"email_subject\" class=\"form-control\" />\n </div>\n <div class=\"mb-3\">\n <label class=\"form-label\">Message</label>\n <textarea id=\"email_body\" class=\"form-control\" rows=\"5\"></textarea>\n </div>\n </div>\n <div class=\"modal-footer\">\n <button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button>\n <button type=\"button\" class=\"btn btn-primary\" id=\"sendEmailBtn\">\n <span class=\"spinner-border spinner-border-sm d-none\" id=\"sendSpinner\"></span>\n Send Email\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <script>\n $('#sendEmailBtn').on('click', function() {\n var $btn = $(this);\n var $spinner = $('#sendSpinner');\n\n // Basic validation\n if (!$('#lead_email').val()) {\n toastr.error('Please enter recipient email.');\n return;\n }\n\n // Show loading state\n $btn.prop('disabled', true);\n $spinner.removeClass('d-none');\n\n var data = {\n to: $('#lead_email').val(),\n cc: $('#lead_cc').val(),\n subject: $('#email_subject').val(),\n body: $('#email_body').val()\n };\n\n // CSRF added automatically by $.ajaxSetup (from global fix above)\n $.post('<?= base_url('leads/send_email') ?>', data, function(res) {\n\n // Update CSRF token\n if (res.csrf_token) {\n csrfToken = res.csrf_token;\n }\n\n if (res.success) {\n toastr.success('Email sent successfully!');\n $('#sendEmailModal').modal('hide');\n } else {\n toastr.error(res.message || 'Failed to send email.');\n }\n\n }).fail(function(xhr) {\n if (xhr.status === 403) {\n toastr.error('CSRF token expired. Please refresh the page.');\n } else {\n toastr.error('Something went wrong. Try again.');\n }\n }).always(function() {\n $btn.prop('disabled', false);\n $spinner.addClass('d-none');\n });\n });\n </script>\n\n\n## What If You Want to Disable CSRF for Specific Routes?\n\nSometimes you need to exclude API endpoints from CSRF (like webhooks). Do it in the filter config:\n\n\n\n // app/Config/Filters.php\n public array $globals = [\n 'before' => [\n 'csrf' => ['except' => [\n 'api/*', // exclude all /api/ routes\n 'webhooks/stripe', // exclude specific webhook\n ]],\n ],\n ];\n\n\n## Common Pitfalls\n\n### ⚠️ 1. Using the same token for multiple rapid requests\n\nIf 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.\n\n### ⚠️ 2. Token in URL (GET request CSRF)\n\nNever put CSRF tokens in GET request URLs. CSRF protection is only needed for state-changing requests — POST, PUT, DELETE.\n\n### ⚠️ 3. SameSite cookie issues on some browsers\n\nIf your app is embedded in an iframe or served cross-origin, set:\n\n\n\n public string $samesite = 'None'; // requires HTTPS\n\n\n### ⚠️ 4. AJAX response is HTML instead of JSON\n\nIf CI4 redirects instead of returning JSON, it means `$redirect = true` in Security.php. Change it to `false` for AJAX apps.\n\n## What to Build Next\n\n * **CodeIgniter 4 REST API** — build and secure endpoints with token auth\n * **CodeIgniter 4 File Upload to AWS S3** — AJAX file upload with progress bar\n * **CodeIgniter 4 + jQuery AJAX CRUD** — full CRUD without page reload\n\n\n\n## Conclusion\n\nCSRF 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.\n\nFollow me for more CodeIgniter production tutorials — 3 new articles every week. 🙌\n\n_Senior PHP Developer · 12+ years building production systems on CodeIgniter, Laravel & WordPress_",
"title": "CodeIgniter 4 CSRF Protection in AJAX Forms — The Complete Fix"
}