jQuery 4 (legacy code and migration to DOM APIs) Module 4 – Ajax, Deferreds and forms
Ajax with jQuery: $.ajax, get, post and load
What $.ajax, $.get, $.post and .load() send and receive: form, JSON and FormData bodies, a CSRF header, and how 404s, bad JSON and timeouts reach fail().
What you will learn
- Read the settings of a $.ajax call and say what its request carries, from the method and URL to the headers
- Predict the body and Content-Type that jQuery sends for an object holding a list, for JSON text and for FormData
- Handle HTTP errors, timeouts, aborts, parse errors and empty answers in fail(), with a message for each
- Send a CSRF token in a header to your own origin only, and name the servers that read X-Requested-With
Before you start
What you’ll learn · Before you start
What you will learn
- Read the settings of a $.ajax call and say what its request carries, from the method and URL to the headers
- Predict the body and Content-Type that jQuery sends for an object holding a list, for JSON text and for FormData
- Handle HTTP errors, timeouts, aborts, parse errors and empty answers in fail(), with a message for each
- Send a CSRF token in a header to your own origin only, and name the servers that read X-Requested-With
Before you start
On this page
An enquiry form sends { name: 'Asha K', services: ['web', 'seo'] } with $.post(), and the PHP script behind it
receives name=Asha+K&services%5B%5D=web&services%5B%5D=seo: a form body, not JSON. Change one setting and the same
enquiry arrives as JSON text that PHP turns into a single meaningless key, and neither side reports an error.
Every request in this lesson comes from a shop page that ran in jsdom with jQuery 4.0.0, most of them with 3.7.1 as well, and went to a test server that wrote down what it received: each section shows a request as a server sees it. You will read what a call sends, predict the body and Content-Type for objects, JSON, arrays and FormData, handle every way a request can fail, and send a CSRF token without breaking the calls a page makes to other sites.
$.get(), $.post() and .load() are $.ajax() with settings filled in
Every Ajax function of jQuery ends in one call. $.get(url, data, success) is $.ajax() with method: 'GET',
$.post() is the same with method: 'POST', and $.getJSON() is a GET with dataType: 'json'
(jQuery.get(), jQuery.post(),
jQuery.getJSON()). .load(url) is a method of a collection instead: it
fetches HTML, with a GET, or a POST when its data is an object, and puts the answer into the selected elements
(.load()). Old code reads more easily once each shorthand is written out as the
settings it stands for:
| Shorthand | The same request with $.ajax() |
|---|---|
$.get(url, data, success) |
{ url, data, success, method: 'GET' } |
$.getJSON(url, data, success) |
{ url, data, success, method: 'GET', dataType: 'json' } |
$.post(url, data, success) |
{ url, data, success, method: 'POST' } |
$(el).load(url, data, done) |
a GET, or a POST when data is an object, with dataType: 'html'; the answer goes into el |
The shop’s stock page makes each call once. The test server logged every request, and this run prints the Accept
header as well:
shop/shorthands.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Stock and offers</title>
</head>
<body>
<section id="deals"></section>
<section id="all"></section>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="shorthands.js"></script>
</body>
</html> shop/shorthands.js
// The shop page's shorthand calls, one after another (each is awaited, so the log stays in order), and what each
// one handed to its callback. The test server logs what each request carried.
const loaded = ($target, url) =>
new Promise((done) => {
$target.load(url, (text, textStatus) => {
console.log(`.load('${url}'): ${textStatus}, the section now holds ${$target.html()}`);
done();
});
});
(async () => {
await $.get('/srv/stock', { sku: 'K-7310' }, (data, textStatus) => {
console.log(`$.get: ${textStatus}, data is an ${typeof data}: ${JSON.stringify(data)}`);
});
await $.getJSON('/srv/stock', { sku: 'L-0042' }).done((data) => {
console.log(`$.getJSON: ${data.sku} has ${data.inStock} in stock`);
});
await $.post('/srv/echo', { sku: 'K-7310', qty: 2 }, (data) => {
console.log(`$.post: ${JSON.stringify(data)}`);
});
await loaded($('#deals'), '/srv/offers #offers');
await loaded($('#all'), '/srv/offers');
})(); // Prints what shop/shorthands.js logged in jsdom and what the test server received (recorded.mjs).
import { show } from './show.mjs';
show('shop/shorthands.html', { accept: true }); Output
server: GET /srv/stock?sku=K-7310 from XMLHttpRequest, answered 200
X-Requested-With: XMLHttpRequest
Accept: */*
$.get: success, data is an object: {"sku":"K-7310","inStock":4}
server: GET /srv/stock?sku=L-0042 from XMLHttpRequest, answered 200
X-Requested-With: XMLHttpRequest
Accept: application/json, text/javascript, */*; q=0.01
$.getJSON: L-0042 has 4 in stock
server: POST /srv/echo from XMLHttpRequest, answered 200
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Accept: */*
body: sku=K-7310&qty=2
$.post: {"ok":true}
server: GET /srv/offers from XMLHttpRequest, answered 200
X-Requested-With: XMLHttpRequest
Accept: text/html, */*; q=0.01
.load('/srv/offers #offers'): success, the section now holds <div id="offers"><p>Delivery in 2 days</p></div>
server: GET /srv/offers from XMLHttpRequest, answered 200
X-Requested-With: XMLHttpRequest
Accept: text/html, */*; q=0.01
the script in offers ran
.load('/srv/offers'): success, the section now holds <div id="offers"><p>Delivery in 2 days</p></div><p id="other">Not wanted here</p><script>console.log("the script in offers ran")</script>
(jQuery 3.7.1 logged the same 23 lines)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node shorthands.mjs
$.get() handed its callback an object, not text: no dataType was given, so jQuery chose one from the answer’s
Content-Type, application/json, and parsed the body (jQuery.ajax()).
$.getJSON() said what it wanted in its Accept header, and .load() asked for text/html. $.post() sent its
object as a form body, sku=K-7310&qty=2, which the next section takes apart. Every request also carried
X-Requested-With: XMLHttpRequest, a header that jQuery adds itself and that the last section comes back to.
The two .load() calls fetched the same fragment and treated its script differently. With a selector after the URL,
'/srv/offers #offers', jQuery kept only that element and removed the scripts before inserting it. Without one, the
whole fragment went in through .html(), and its script ran: the run logged “the script in offers ran” before the
callback, as the documentation of .load() describes.
Point .load() only at HTML you trust
.load(url) without a selector runs every script in the answer, so use it only for your own server’s HTML, and add
a selector when you want one element of a page. Text that users typed never goes in through it, or through
.html(): insert that with .text().
Checkpoint
Check: The shorthands and the settings they fill in
What the server receives depends on data, contentType and processData
The data setting is turned into a request in one of three ways. A plain object goes through $.param(), which
writes the bracket names that PHP reads, services[] for each item of a list, and is sent with the default
contentType, application/x-www-form-urlencoded; charset=UTF-8
(jQuery.ajax()). A string is sent as it is, still labelled with that default
type unless you change contentType; under the form type, jQuery’s one change is to turn each %20 in it into +
(src/ajax.js). And with processData: false, jQuery leaves the data alone, which is
meant for objects the browser sends itself, such as FormData. Step through one enquiry sent with five settings, and
watch what reaches PHP:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The enquiry. The page holds one enquiry: name "Asha K", services, a list of web and seo, and budget, the number 50000. Each step sends it with different $.ajax settings.
- An object. $.post(url, enquiry) encodes the object with $.param(): services becomes services[]=web&services[]=seo, the space a +, and the Content-Type says it is a form.
- PHP reads a form. PHP fills $_POST from a form body: name is "Asha K" and services a list of two. The budget arrives as the string "50000", because a form body holds only text.
- JSON text as a form. data: JSON.stringify(enquiry) without a contentType sends JSON text, while the Content-Type still says form. PHP parses the JSON as a form and builds one odd key: $_POST["name"] is not set.
- JSON as JSON. Add contentType "application/json" and the label matches the body. $_POST stays empty for a JSON body; the PHP script reads php://input and json_decode() gives the enquiry, budget as a number.
- processData false. processData: false tells jQuery to leave data alone. With a plain object nothing encodes it, so the body is the text [object Object] and $_POST is empty.
- FormData. A FormData object goes as multipart/form-data, each field and the file brief.txt in a part of its own between boundary lines. PHP puts the fields in $_POST and the file in $_FILES.
The enquiry page makes those five requests, then two GET requests, and the test server logged the Content-Type and the body of each:
shop/bodies.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Enquiry bodies</title>
</head>
<body>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="bodies.js"></script>
</body>
</html> shop/bodies.js
// One enquiry sent five ways, then two GET requests. The test server logs the Content-Type and the body of each.
const enquiry = { name: 'Asha K', services: ['web', 'seo'], budget: 50000 };
(async () => {
// 1. A plain object: jQuery encodes it with $.param() as a form body.
await $.post('/srv/echo', enquiry);
// 2. JSON text, while the Content-Type still says it is a form.
await $.ajax({ url: '/srv/echo', method: 'POST', data: JSON.stringify(enquiry) });
// 3. JSON text with a JSON Content-Type.
await $.ajax({ url: '/srv/echo', method: 'POST', contentType: 'application/json', data: JSON.stringify(enquiry) });
// 4. processData: false with a plain object, so nothing encodes it.
await $.ajax({ url: '/srv/echo', method: 'POST', processData: false, data: enquiry });
// 5. A FormData object with a file in it.
const form = new FormData();
form.append('name', enquiry.name);
form.append('brief', new Blob(['Two pages, in Hindi and English'], { type: 'text/plain' }), 'brief.txt');
await $.ajax({ url: '/srv/echo', method: 'POST', data: form });
// GET: the data goes into the query string, and cache: false adds a number to the URL.
await $.get('/srv/echo', { name: enquiry.name, ids: [3, 4] });
await $.ajax({ url: '/srv/echo', cache: false });
console.log('sent: 5 bodies, then 2 GET requests');
})(); // Prints what shop/bodies.js logged in jsdom and what the test server received (recorded.mjs).
import { show } from './show.mjs';
show('shop/bodies.html'); Output
server: POST /srv/echo from XMLHttpRequest, answered 200
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
body: name=Asha+K&services%5B%5D=web&services%5B%5D=seo&budget=50000
server: POST /srv/echo from XMLHttpRequest, answered 200
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
body: {"name":"Asha K","services":["web","seo"],"budget":50000}
server: POST /srv/echo from XMLHttpRequest, answered 200
Content-Type: application/json
X-Requested-With: XMLHttpRequest
body: {"name":"Asha K","services":["web","seo"],"budget":50000}
server: POST /srv/echo from XMLHttpRequest, answered 200
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
body: [object Object]
server: POST /srv/echo from XMLHttpRequest, answered 200
Content-Type: multipart/form-data; boundary=BOUNDARY
X-Requested-With: XMLHttpRequest
body: --BOUNDARY
Content-Disposition: form-data; name="name"
Asha K
--BOUNDARY
Content-Disposition: form-data; name="brief"; filename="brief.txt"
Content-Type: text/plain
Two pages, in Hindi and English
--BOUNDARY--
server: GET /srv/echo?name=Asha%20K&ids%5B%5D=3&ids%5B%5D=4 from XMLHttpRequest, answered 200
X-Requested-With: XMLHttpRequest
server: GET /srv/echo?_=<number> from XMLHttpRequest, answered 200
X-Requested-With: XMLHttpRequest
sent: 5 bodies, then 2 GET requests
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node bodies.mjs
The object became name=Asha+K&services%5B%5D=web&services%5B%5D=seo&budget=50000. The space is a +, because
jQuery turns the %20 of $.param() into + in a form body, the form encoding of the
URL Standard, a step that moved into $.ajax() in
jQuery 3.0 (3.0 upgrade guide). The JSON string of the second request went
out unchanged, under the form’s Content-Type, and only the third request labelled it application/json.
processData: false with a plain object sent the text [object Object]. The FormData object went as
multipart/form-data, with the file in a part of its own; jQuery 4 needs no extra setting for that.
JSON text with the form's Content-Type
Wrong: $.ajax({ method: 'POST', data: JSON.stringify(enquiry) }), which labels JSON as a form. Right: add
contentType: 'application/json', so the label matches the body, or pass the object itself when the server reads
form fields.
The GET requests put their data in the query string instead, where the space stayed %20, and cache: false added
_= and a number that changes with every request, so no cache along the way can answer it.
FormData on jQuery 3
jQuery 3 encodes every object with $.param(), FormData included, and throws, so its code sends FormData with
processData: false and contentType: false. jQuery 4 sets both for FormData itself; the
jQuery 4 Ajax lesson runs that call on both versions.
PHP fills $_POST only from a form body or a multipart body, and parses a form body as parse_str() parses a query
string ($_POST,
parse_str). A JSON body leaves $_POST empty, and the script
reads it from php://input. This program decodes the bodies the
server received:
<?php
// decode.php: what PHP makes of three of the bodies that bodies.js sent (bodies.mjs prints them). For a body sent as
// application/x-www-form-urlencoded, PHP fills $_POST as parse_str() parses a query string; a JSON body leaves $_POST
// empty, and the script reads it from php://input with json_decode() instead.
$formBodies = [
'the object' => 'name=Asha+K&services%5B%5D=web&services%5B%5D=seo&budget=50000',
'JSON text sent as a form' => '{"name":"Asha K","services":["web","seo"],"budget":50000}',
'processData: false' => '[object Object]',
];
foreach ($formBodies as $from => $body) {
parse_str($body, $post);
echo $from, ': $_POST = ', json_encode($post, JSON_UNESCAPED_SLASHES), "\n";
echo ' $_POST[\'name\'] is ', isset($post['name']) ? '"' . $post['name'] . '"' : 'not set', "\n";
}
$json = '{"name":"Asha K","services":["web","seo"],"budget":50000}';
$data = json_decode($json, true, 16, JSON_THROW_ON_ERROR);
echo 'JSON with a JSON Content-Type: $_POST = [], json_decode() gives ', json_encode($data, JSON_UNESCAPED_SLASHES), "\n";
echo ' budget is ', get_debug_type($data['budget']), ' here, and ', get_debug_type('50000'), " in a form body\n"; Output
the object: $_POST = {"name":"Asha K","services":["web","seo"],"budget":"50000"}
$_POST['name'] is "Asha K"
JSON text sent as a form: $_POST = {"{\"name\":\"Asha_K\",\"services\":":{"\"web\",\"seo\"":""}}
$_POST['name'] is not set
processData: false: $_POST = []
$_POST['name'] is not set
JSON with a JSON Content-Type: $_POST = [], json_decode() gives {"name":"Asha K","services":["web","seo"],"budget":50000}
budget is int here, and string in a form body
Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php decode.php
The form body gave PHP all three fields, the budget as the string "50000", since a form body holds only text. The
JSON text sent as a form gave it one odd key, the JSON up to its first [ with the space turned into _, so
$_POST['name'] was not set, and [object Object] gave it nothing. None of these requests failed: the server answered 200 to every one.
Key idea
A server reads a body by its Content-Type, so the label must match the bytes: an object becomes a form body, a string
goes as it is under whatever contentType says, and processData: false sends only what the browser can send
itself.
Checkpoint
Check: What data, contentType and processData send
Every status outside 200–299, a bad JSON body and a timeout end in fail()
A request succeeds when its status is from 200 to 299, or 304, and its body converts to the dataType it asked for
(src/ajax.js); anything else calls the fail() callbacks
with three values: the jqXHR, a textStatus and an errorThrown (jQuery.ajax()). The jqXHR: The object that $.ajax() and its shorthands return: a promise of the request's result that also holds the status, the response text and the headers, with abort() to cancel the request. is the object $.ajax()
returns, a promise of the result that also holds the status and the response text. Follow each of the page’s requests
down the column:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The request leaves. $.ajax() sends the request and, when the settings give a timeout, starts a clock. abort() or the clock can end the request before any answer arrives.
- The clock runs out. The page asked /srv/slow with timeout: 100, and the server needed 300 ms. jQuery stopped waiting and called fail() with status 0, textStatus "timeout" and errorThrown "timeout".
- A 404 arrives. For /srv/missing the server answered 404. Any status outside 200–299, other than 304, sends the request to fail() with textStatus "error" and errorThrown "Not Found", the status text.
- A 500 is a failure too. The 500 of /srv/broken went the same way: status 500, textStatus "error", errorThrown "Internal Server Error". The body of a failed request is not passed on as data; it stays in jqXHR.responseText.
- 200, but an empty body. For /srv/empty with dataType "json" the status was 200, so the answer went on to be converted. JSON.parse() threw on the empty body, and fail() got textStatus "parsererror".
- 204 No Content. The POST to /srv/subscribe got 204. jQuery does not parse a body that is not there: done() ran with textStatus "nocontent" and data undefined, even with dataType "json".
- A success. A status of 200–299 or 304 and a body that converts reach done() and then(), with the data, "success" and the jqXHR. Every other path above ended in fail().
The error page makes those requests one after another. Its fail() callbacks log the three values, and a handler on
document logs every failure a second time:
shop/errors.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>When a request goes wrong</title>
</head>
<body>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="errors.js"></script>
</body>
</html> shop/errors.js
// Requests that do not succeed, each with a fail() callback that logs the three values jQuery passes it, and a
// handler on document that sees every failure of a request whose global setting is left at true.
$(document).on('ajaxError', (event, jqXHR, settings) => {
console.log(` ajaxError on document: ${settings.url}, status ${jqXHR.status}`);
});
const report = (label) => (jqXHR, textStatus, errorThrown) => {
console.log(`${label}: status ${jqXHR.status}, textStatus "${textStatus}", errorThrown "${errorThrown}"`);
};
// Waits until a request has finished, whatever the outcome, so that the lines stay in order. done() gets no argument:
// a promise resolved with the jqXHR itself would take on the request's failure.
const finished = (jqXHR) => new Promise((done) => jqXHR.always(() => done()));
(async () => {
await finished($.ajax('/srv/missing').fail(report('404')));
await finished($.ajax('/srv/broken').fail(report('500')));
await finished($.ajax({ url: '/srv/empty', dataType: 'json' }).fail(report('empty body as JSON')));
await finished($.ajax({ url: '/srv/slow', timeout: 100 }).fail(report('timeout of 100 ms')));
await finished($.ajax({ url: '/srv/missing', global: false }).fail(report('404 with global: false')));
const pending = $.ajax('/srv/slow').fail(report('abort() after 50 ms'));
setTimeout(() => pending.abort(), 50);
await finished(pending);
await finished(
$.ajax({ url: '/srv/subscribe', method: 'POST', dataType: 'json' }).done((data, textStatus, jqXHR) => {
console.log(`204: done() ran, status ${jqXHR.status}, textStatus "${textStatus}", data ${data}`);
}),
);
})(); // Prints what shop/errors.js logged in jsdom and what the test server received (recorded.mjs).
import { show } from './show.mjs';
show('shop/errors.html'); Output
server: GET /srv/missing from XMLHttpRequest, answered 404 X-Requested-With: XMLHttpRequest 404: status 404, textStatus "error", errorThrown "Not Found" ajaxError on document: /srv/missing, status 404 server: GET /srv/broken from XMLHttpRequest, answered 500 X-Requested-With: XMLHttpRequest 500: status 500, textStatus "error", errorThrown "Internal Server Error" ajaxError on document: /srv/broken, status 500 server: GET /srv/empty from XMLHttpRequest, answered 200 X-Requested-With: XMLHttpRequest empty body as JSON: status 200, textStatus "parsererror", errorThrown "SyntaxError: Unexpected end of JSON input" ajaxError on document: /srv/empty, status 200 server: GET /srv/slow from XMLHttpRequest, answered 200 X-Requested-With: XMLHttpRequest timeout of 100 ms: status 0, textStatus "timeout", errorThrown "timeout" ajaxError on document: /srv/slow, status 0 server: GET /srv/missing from XMLHttpRequest, answered 404 X-Requested-With: XMLHttpRequest 404 with global: false: status 404, textStatus "error", errorThrown "Not Found" server: GET /srv/slow from XMLHttpRequest, answered 200 X-Requested-With: XMLHttpRequest abort() after 50 ms: status 0, textStatus "abort", errorThrown "abort" ajaxError on document: /srv/slow, status 0 server: POST /srv/subscribe from XMLHttpRequest, answered 204 X-Requested-With: XMLHttpRequest 204: done() ran, status 204, textStatus "nocontent", data undefined (jQuery 3.7.1 logged the same 26 lines)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node errors.mjs
| The request | status | textStatus, errorThrown |
|---|---|---|
| 404 or 500 | the status | "error", the status text (empty over HTTP/2) |
200, empty body, dataType: 'json' |
200 | "parsererror", the SyntaxError |
No answer within timeout |
0 | "timeout", "timeout" |
abort() before the answer |
0 | "abort", "abort" |
| 204 No Content | 204, in done() |
"nocontent", no error |
Two of those rows surprise people. A 200 can fail: the documentation says an empty answer is rejected for JSON, and
the server should send null or {} instead. And a 204 succeeds with data undefined, because there is no body to
parse. For a parse error, errorThrown is the SyntaxError that JSON.parse() threw, and for an HTTP error it is the
status text, which an answer over HTTP/2 does not carry, so there it is an empty string
(jQuery.ajax()). Branch on textStatus and the status, never on errorThrown.
The handler on document saw every failure except the one sent with global: false. Global events such as
ajaxError, ajaxStart and ajaxStop are attached with .on(): the shorthand methods .ajaxError() and
.ajaxStart() were deprecated in jQuery 3.5 (.ajaxStart()), and
.on() is what the ajaxError event page shows. A global handler suits logging;
the message a form shows belongs in the request’s own fail(), next to the form.
One message for each way to fail
Read textStatus first, then the status: a timeout, an abort and a lost connection all have status 0, and a parse
error has status 200. The practice section below builds that function.
Checkpoint
Check: How a request ends in fail()
A CSRF token belongs in a header, sent to your own origin only
A CSRF token: A secret value that a server puts in its own pages and checks on every request that changes data. A request forged by another site cannot know it, so the server refuses that request. proves that a request comes from the site’s own page: the server puts a
secret value in the page and refuses any request that changes data without it. OWASP’s cheat sheet recommends
sending it from JavaScript in a custom request header, which the server then checks
(CSRF Prevention Cheat Sheet).
Laravel’s documentation puts the token in a meta tag and adds it to every jQuery request with
$.ajaxSetup({ headers: { 'X-CSRF-TOKEN': … } }), and its middleware checks that header
(Laravel CSRF protection). That one line has a cost. Step through the
enquiry and the exchange rates of the shop page, first with $.ajaxSetup(), then with a prefilter:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- No token. The enquiry goes out with X-Requested-With, which jQuery adds to every same-origin request, but without the CSRF token. The server refuses it as Laravel does: 419 Page Expired.
- $.ajaxSetup() adds it. $.ajaxSetup({ headers: { "X-CSRF-TOKEN": token } }) adds the header to every request from now on. The same enquiry carries it, and the server answers 201 Created.
- The rates ask first. The rates come from another origin, and the extra header makes the browser ask first: an OPTIONS request names X-CSRF-TOKEN in Access-Control-Request-Headers.
- The API says no. The API allows only X-Requested-With, so its answer has no CORS headers. The GET is never sent, and the request fails with status 0: the exchange rates disappear from the page.
- Same origin only. A prefilter adds the token only when options.crossDomain is false. The enquiry still carries it and gets 201, and the shop page never hands its token to another site.
- A plain GET. The rates request now has no custom header, not even X-Requested-With, which jQuery leaves off cross-origin requests. No OPTIONS is needed, and the API answers 200 with Access-Control-Allow-Origin.
The two pages make the same calls; the second adds the token only to requests for its own origin: The scheme, host and port of an address, such as https://shop.example. A request to any other origin is cross-origin, and the browser applies the CORS rules to it.:
shop/token-setup.js
// The enquiry first goes without the CSRF token, then with the token added by $.ajaxSetup(), the way Laravel's
// documentation shows. The page also loads exchange rates from another origin, api.example.in.
const send = () => $.post('/srv/enquiry', { name: 'Asha K' });
const rates = () => $.getJSON('https://api.example.in/rates');
const outcome = async (label, request) => {
try {
console.log(`${label}: ${JSON.stringify(await request())}`);
} catch (jqXHR) {
console.log(`${label} failed: status ${jqXHR.status}`);
}
};
(async () => {
await outcome('enquiry without the token', send);
const token = $('meta[name="csrf-token"]').attr('content');
$.ajaxSetup({ headers: { 'X-CSRF-TOKEN': token } });
await outcome('enquiry with the token', send);
await outcome('rates', rates);
})(); shop/token-prefilter.js
// The same calls, with the token added by a prefilter only to requests for the shop's own origin.
const token = $('meta[name="csrf-token"]').attr('content');
$.ajaxPrefilter((options, originalOptions, jqXHR) => {
if (!options.crossDomain) jqXHR.setRequestHeader('X-CSRF-TOKEN', token);
});
const outcome = async (label, request) => {
try {
console.log(`${label}: ${JSON.stringify(await request())}`);
} catch (jqXHR) {
console.log(`${label} failed: status ${jqXHR.status}`);
}
};
(async () => {
await outcome('enquiry', () => $.post('/srv/enquiry', { name: 'Asha K' }));
await outcome('rates', () => $.getJSON('https://api.example.in/rates'));
})(); shop/token-setup.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="csrf-token" content="tok-5d1c">
<title>Enquiry with a CSRF token</title>
</head>
<body>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="token-setup.js"></script>
</body>
</html> // Prints what shop/token-setup.js logged in jsdom and what the test server received (recorded.mjs).
import { show } from './show.mjs';
show('shop/token-setup.html'); Output
server: POST /srv/enquiry from XMLHttpRequest, answered 419
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
body: name=Asha+K
enquiry without the token failed: status 419
server: POST /srv/enquiry from XMLHttpRequest, answered 201
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
X-CSRF-TOKEN: tok-5d1c
body: name=Asha+K
enquiry with the token: {"saved":true}
server: OPTIONS https://api.example.in/rates from XMLHttpRequest, answered 204
Origin: https://shop.example
Access-Control-Request-Method: GET
Access-Control-Request-Headers: X-CSRF-TOKEN
rates failed: status 0
(jQuery 3.7.1 logged the same 16 lines)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node token-setup.mjs
// Prints what shop/token-prefilter.js logged in jsdom and what the test server received (recorded.mjs).
import { show } from './show.mjs';
show('shop/token-prefilter.html'); Output
server: POST /srv/enquiry from XMLHttpRequest, answered 201
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
X-CSRF-TOKEN: tok-5d1c
body: name=Asha+K
enquiry: {"saved":true}
server: GET https://api.example.in/rates from XMLHttpRequest, answered 200
Origin: https://shop.example
rates: {"INR":1,"USD":0.0113}
(jQuery 3.7.1 logged the same 9 lines)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node token-prefilter.mjs
The test server checks the token on every enquiry, as Laravel 12’s ValidateCsrfToken middleware does
(Laravel 12 CSRF protection), and answered 419 Page Expired, Laravel’s status
for a token that does not match, until the token came with the request. With $.ajaxSetup(), the token header was
also set on the call to api.example.in. A custom header turns a cross-origin request into one the
browser must ask about first: an OPTIONS request, the preflight request: An OPTIONS request that a browser sends before a cross-origin request with a custom header or another method than GET, HEAD or POST, asking the server whether it allows it; the real request goes only if the answer allows it., listed
X-CSRF-TOKEN in Access-Control-Request-Headers (Fetch Standard).
The API allowed only X-Requested-With, so the GET was never sent and the rates failed with status 0. An API that
allowed any header would have received the shop’s token instead, which is why OWASP’s cheat sheet warns against
attaching the token to every cross-origin destination. The jQuery documentation advises against $.ajaxSetup() for
another reason: its settings reach every later call, plugins included
(jQuery.ajaxSetup()).
Laravel 13 checks the origin first
Laravel 13’s PreventRequestForgery middleware first reads the Sec-Fetch-Site header that browsers send over
HTTPS, and lets a same-origin request through without a token; it checks the token only when that header does not
settle the question, as in an older browser or over plain HTTP. Laravel 12 checks the token on every request that
changes data. Code that sends the token works with both
(Laravel 13 CSRF protection).
The prefilter runs before each request, after jQuery has worked out options.crossDomain, and adds the header only
when it is false (jQuery.ajaxPrefilter()). The enquiry kept its
token, and the rates went as a plain GET with only an Origin header.
X-Requested-With: A request header, X-Requested-With: XMLHttpRequest, that jQuery adds to its same-origin Ajax requests. Laravel, Symfony and Rails read it to tell an Ajax call from a page load; fetch() never sends it. follows the same rule inside jQuery: the XHR transport adds it to
same-origin requests only, because “conditions for a preflight are akin to a jigsaw puzzle”, as the comment in its
source puts it (src/ajax/xhr.js). Servers still read
it. Laravel’s request()->ajax() calls Symfony’s isXmlHttpRequest(), which compares the header with
XMLHttpRequest (Laravel,
Symfony), and Rails’
request.xhr? reads it too (ActionDispatch::Request).
Code that replaces $.ajax() has to keep sending it where such a check decides what the server returns.
Keep the token on your own origin
Add the CSRF token in a prefilter that checks options.crossDomain, or in the settings of each call to your own
server, never with $.ajaxSetup() for every request. The token is a secret of your session; another site has no use
for it except to misuse it.
Checkpoint
Check: CSRF tokens and X-Requested-With
Practise: fix the enquiry request
The enquiry form has two bugs left from this lesson: its settings send JSON labelled as a form without the token, and
its fail() callback shows a message only for a 404. Each step has starting code, a first hint and sample tests;
the tests read plain objects shaped like the settings and the jqXHR, so they run without jQuery:
Code lab · 2 steps
Fix the enquiry form's request
Send the enquiry as JSON with its CSRF token, then show a message for every way the request can fail.
- Send the enquiry as JSON, with its CSRF token To do
- Show a message for every way the request can fail To do
Exercise · Easy · JavaScript
Send the enquiry as JSON, with its CSRF token
The enquiry form builds the settings it passes to $.ajax() in one function, enquirySettings(enquiry, token). Its PHP endpoint reads JSON from php://input and checks the CSRF token, and today it refuses every enquiry: the body is JSON text labelled as a form, and the token never leaves the page. Fix the function in enquiry.mjs so that the settings it returns:
- send a
POSTto/srv/enquiryand ask for a JSON answer (dataType: 'json'); - send the enquiry as JSON text with the Content-Type
application/json; - carry the token in an
X-CSRF-TOKENheader, and no other header.
The function must leave the enquiry it is given unchanged. The tests read the settings object, so they run without jQuery and without a browser.
Starter code · enquiry.mjs
/**
* The $.ajax() settings that send an enquiry to the shop's PHP endpoint, which reads JSON and checks the CSRF token.
* @param {{ name: string, services: string[], budget: number }} enquiry @param {string} token
*/
export function enquirySettings(enquiry, token) {
return {
url: '/srv/enquiry',
method: 'POST',
data: JSON.stringify(enquiry),
dataType: 'json',
};
} The sample tests · enquiry.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { enquirySettings } from './enquiry.mjs';
const enquiry = { name: 'Asha K', services: ['web', 'seo'], budget: 50000 };
test('posts to /srv/enquiry and asks for a JSON answer', () => {
const s = enquirySettings(enquiry, 'tok-5d1c');
assert.equal(s.url, '/srv/enquiry');
assert.equal(String(s.method ?? s.type).toUpperCase(), 'POST');
assert.equal(s.dataType, 'json');
});
test('sends the enquiry as JSON text with the JSON Content-Type', () => {
const s = enquirySettings(enquiry, 'tok-5d1c');
assert.equal(s.contentType, 'application/json');
assert.equal(typeof s.data, 'string');
assert.deepEqual(JSON.parse(s.data), enquiry);
});
test('carries the token in X-CSRF-TOKEN and in no other header', () => {
assert.deepEqual(enquirySettings(enquiry, 'tok-5d1c').headers, { 'X-CSRF-TOKEN': 'tok-5d1c' });
assert.deepEqual(enquirySettings(enquiry, 'tok-9a0e').headers, { 'X-CSRF-TOKEN': 'tok-9a0e' });
});
test('leaves the enquiry it is given unchanged', () => {
const before = JSON.stringify(enquiry);
enquirySettings(enquiry, 'tok-5d1c');
assert.equal(JSON.stringify(enquiry), before);
}); A hint
data: JSON.stringify(enquiry) already makes the body JSON text, but jQuery labels a string body with its default contentType, the form type. Set contentType yourself, and add a headers object with the one header.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · JavaScript
Show a message for every way the request can fail
The enquiry form calls failMessage(jqXHR, textStatus) in its fail() callback and shows the text it returns under the form. Today it returns a message only for a 404, so most failures show nothing at all. Write it in message.mjs, checking the cases in this order:
| The request | Return | | --- | --- | | textStatus is 'abort' (the page cancelled it) | '' | | textStatus is 'timeout' | 'The server took too long to answer. Please try again.' | | textStatus is 'parsererror' | 'The server sent an answer the page cannot read.' | | jqXHR.status is 0 | 'No connection to the server. Check your network and try again.' | | jqXHR.status is 419 | 'Your session has expired. Reload the page and send the form again.' | | jqXHR.status is 422 | the first message in jqXHR.responseJSON.errors, or 'Please check the form and send it again.' when there is none | | anything else | ` Something went wrong (status ${jqXHR.status}). Please try again. ` |
A 422 answer has Laravel's validation shape, { message, errors: { field: [messages] } }, and responseJSON is missing when the body was not JSON. The tests pass objects shaped like a jqXHR, without jQuery.
Starter code · message.mjs
/**
* The text the enquiry form shows when a request fails, from the jqXHR and the textStatus that fail() receives.
* @param {{ status: number, responseJSON?: { errors?: Record<string, string[]> } }} jqXHR @param {string} textStatus
* @returns {string}
*/
export function failMessage(jqXHR, textStatus) {
if (jqXHR.status === 404) return 'Not found.';
} The sample tests · message.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { failMessage } from './message.mjs';
test('says nothing for a request the page cancelled itself', () => {
assert.equal(failMessage({ status: 0 }, 'abort'), '');
});
test('tells a timeout from a lost connection, though both have status 0', () => {
assert.equal(failMessage({ status: 0 }, 'timeout'), 'The server took too long to answer. Please try again.');
assert.equal(failMessage({ status: 0 }, 'error'), 'No connection to the server. Check your network and try again.');
});
test('reports an answer that is not JSON, though its status is 200', () => {
assert.equal(failMessage({ status: 200 }, 'parsererror'), 'The server sent an answer the page cannot read.');
});
test('asks for a reload when the CSRF token has expired', () => {
assert.equal(failMessage({ status: 419 }, 'error'), 'Your session has expired. Reload the page and send the form again.');
});
test('shows the first validation message of a 422, or a general one', () => {
const invalid = { status: 422, responseJSON: { message: 'Invalid.', errors: { email: ['The email field must be a valid email address.'], name: ['Too short.'] } } };
assert.equal(failMessage(invalid, 'error'), 'The email field must be a valid email address.');
assert.equal(failMessage({ status: 422 }, 'error'), 'Please check the form and send it again.');
assert.equal(failMessage({ status: 422, responseJSON: { errors: {} } }, 'error'), 'Please check the form and send it again.');
});
test('names the status of any other failure', () => {
assert.equal(failMessage({ status: 500 }, 'error'), 'Something went wrong (status 500). Please try again.');
assert.equal(failMessage({ status: 404 }, 'error'), 'Something went wrong (status 404). Please try again.');
}); A hint
Test textStatus before jqXHR.status: a timeout and an abort both have status 0, and a parse error has status 200. For a 422, Object.values(errors) gives the lists of messages, field by field.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then work through the lesson’s questions. They come one by one, starting from the idea you know least, and each answer you pick explains itself:
Practice that adapts to you
Practise this lesson
9 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up, for freshers and 2–5 years: how do $.get(), $.post() and $.ajax() differ?
$.ajax() is the one function that sends every request; $.get() and $.post() call it with the method filled in
and take the URL, the data, a success callback and a dataType as arguments. They return the same jqXHR, so .done(),
.fail() and await work on all three. Use $.ajax() when a call needs a setting the shorthands cannot pass, such as
contentType, headers, timeout or processData; use the shorthands for plain GETs and form posts. $.getJSON()
is a GET that asks for JSON, and .load() puts the HTML it fetches into the page.
Two harder questions from this module’s bank start from this lesson: an endpoint whose $_POST arrives empty, and a
CSRF header that broke a call to another site. Try each one before you read its first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
After a refactor, a PHP endpoint that saved enquiries finds $_POST['name'] missing, although the browser shows the request answered with 200. The new code sends the enquiry with $.ajax(). How do you find the cause, and how do you fix it without breaking the endpoint's other callers?
-
Mid to senior
A team adds $.ajaxSetup({ headers: { 'X-CSRF-TOKEN': token } }) so that every form post carries Laravel's CSRF token. The next day a currency widget, which calls another company's API, shows no rates, and the console reports a CORS error. Explain what happened and how you would fix it.
Key takeaways
$.get(),$.post(),$.getJSON()and.load()all call$.ajax()with settings filled in, and.load()without a selector runs the scripts of the HTML it inserts.- An object is sent as a form body with brackets and
+for spaces, a string is sent as it is under the default form Content-Type, and JSON needscontentType: 'application/json', or PHP’s$_POSTgets nothing it can use. fail()runs for every status outside 200–299 and 304, for a body that does not parse, for a timeout and for an abort; a 204 succeeds with no data.- Read
textStatusbefore the status: timeouts, aborts and lost connections all have status 0. - Send a CSRF token only to your own origin, from a prefilter or the call’s settings: with
$.ajaxSetup()it reaches other origins, and its custom header forces a preflight there.
Terms in this lesson
- CSRF token
- A secret value that a server puts in its own pages and checks on every request that changes data. A request forged by another site cannot know it, so the server refuses that request.
- jqXHR
- The object that $.ajax() and its shorthands return: a promise of the request's result that also holds the status, the response text and the headers, with abort() to cancel the request.
- origin
- The scheme, host and port of an address, such as https://shop.example. A request to any other origin is cross-origin, and the browser applies the CORS rules to it.
- preflight request
- An OPTIONS request that a browser sends before a cross-origin request with a custom header or another method than GET, HEAD or POST, asking the server whether it allows it; the real request goes only if the answer allows it.
- X-Requested-With
- A request header, X-Requested-With: XMLHttpRequest, that jQuery adds to its same-origin Ajax requests. Laravel, Symfony and Rails read it to tell an Ajax call from a page load; fetch() never sends it.
References
- jQuery.ajax() (OpenJS Foundation (jQuery API documentation))
- jQuery.get() (OpenJS Foundation (jQuery API documentation))
- jQuery.post() (OpenJS Foundation (jQuery API documentation))
- jQuery.getJSON() (OpenJS Foundation (jQuery API documentation))
- .load() (OpenJS Foundation (jQuery API documentation))
- jQuery.ajaxSetup() (OpenJS Foundation (jQuery API documentation))
- jQuery.ajaxPrefilter() (OpenJS Foundation (jQuery API documentation))
- .ajaxStart(): the deprecated shorthand (OpenJS Foundation (jQuery API documentation))
- ajaxError event (OpenJS Foundation (jQuery API documentation))
- jQuery 4.0.0 source: src/ajax/xhr.js (the X-Requested-With header) (OpenJS Foundation (jQuery))
- jQuery 4.0.0 source: src/ajax.js (success statuses, %20 to + in form bodies) (OpenJS Foundation (jQuery))
- jQuery Core 3.0 Upgrade Guide: jQuery.param() and %20 (OpenJS Foundation (jQuery))
- URL Standard: application/x-www-form-urlencoded (WHATWG)
- Fetch Standard: the CORS protocol and the preflight request (WHATWG)
- XMLHttpRequest Standard (WHATWG)
- Cross-Site Request Forgery Prevention Cheat Sheet (OWASP Foundation)
- Laravel 13.x: CSRF Protection (X-CSRF-TOKEN, Sec-Fetch-Site) (Laravel)
- Laravel 12.x: CSRF Protection (ValidateCsrfToken) (Laravel)
- Laravel 13.x: Validation, the error response format (Laravel)
- Laravel framework source: Request::ajax() (Laravel)
- Symfony source: Request::isXmlHttpRequest() (Symfony)
- Ruby on Rails API: ActionDispatch::Request#xhr? (Ruby on Rails)
- PHP Manual: $_POST (The PHP Group (php.net))
- PHP Manual: parse_str (The PHP Group (php.net))
- PHP Manual: php://input (The PHP Group (php.net))
- PHP Manual: POST method uploads (The PHP Group (php.net))
- jsdom (jsdom project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress