External Publication
Visit Post

CodeIgniter 4 CSRF Protection in AJAX Forms — The Complete Fix

DEV Community [Unofficial] June 29, 2026
Source

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

Discussion in the ATmosphere

Loading comments...