DEV Community

sunakshi Thakur
sunakshi Thakur

Posted on

CodeIgniter 4 CSRF Protection in AJAX Forms — The Complete Fix

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

Top comments (0)