{
  "$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"
}