
What is cross-site request forgery (CSRF)?
Cross-site request forgery (CSRF) is an attack that tricks a logged-in user's browser into sending a request to a website they're signed into, without their knowledge, so that the site performs an action the user never intended, such as changing their email address, deleting content, or creating a new admin account. It works because browsers automatically attach cookies to requests, and it's prevented mainly by requiring an unpredictable token (called a nonce in WordPress) with every state-changing request, along with SameSite cookies and proper use of HTTP methods.
CSRF is sometimes called a "one-click attack" or "session riding." It doesn't steal passwords or read data directly. Instead, it borrows the victim's existing session to make changes. This article explains how CSRF works, why it's dangerous, how it differs from XSS, and how to protect your forms, AJAX handlers, and APIs, with practical examples for WordPress, plain PHP, and Node.js.
How Does a CSRF Attack Work?
To understand CSRF, you need to know how browsers handle cookies. When you log in to a website, it sets a session cookie. From then on, your browser sends that cookie with every request to that site, regardless of which page or site triggered the request. That's what keeps you logged in.
A CSRF attack takes advantage of this in four steps:
-
The victim logs in: An administrator logs in to their site, and the browser stores the session cookie.
-
The victim visits a malicious page: Later, while still logged in, they open a page controlled by the attacker, perhaps from a link in an email, a forum post, or an ad.
-
The page sends a forged request: The malicious page contains a hidden form or image tag that sends a request to the victim's site, for example to change the admin email.
-
The site accepts it: Because the browser attaches the session cookie, the site sees a legitimate, authenticated request from the admin and performs the action.
Here's a conceptual illustration of the kind of hidden form an attacker might use:
<form action="https://example.com/account/update-email" method="POST" id="f">
<input type="hidden" name="email" value="attacker@example.net" />
</form>
<script>
document.getElementById("f").submit();
</script>
The victim never sees anything happen. If the target site has no CSRF protection, the email change goes through. From there, the attacker can request a password reset to the new address and take over the account.
What Makes CSRF Possible
A CSRF attack needs three things:
- A relevant action: Something worth forging, such as changing credentials, transferring money, publishing content, or modifying settings.
- Cookie-based session handling: The site identifies the user only by cookies (or other credentials the browser sends automatically).
- No unpredictable parameters: The attacker can construct the entire request in advance because nothing in it is secret.
Remove any one of those and the attack fails. CSRF tokens remove the third.
Why CSRF Is Dangerous
The impact depends on who the victim is and what the vulnerable action does:
- For regular users: Changing their email, password, shipping address, or privacy settings.
- For administrators: Creating new admin accounts, changing site settings, installing plugins, or editing content, which can lead to full site takeover.
- For e-commerce: Placing orders, changing payment details, or modifying account information.
On WordPress, CSRF vulnerabilities in plugins are reported regularly. They often appear as "missing nonce check" in vulnerability advisories.
CSRF vs. XSS
CSRF and XSS are often confused, but they're different:
| Aspect | CSRF | XSS |
|---|---|---|
| What it abuses | The site's trust in the user's browser | The user's trust in the site's content |
| Runs code on site? | No, it only sends requests | Yes, attacker's JavaScript runs on your pages |
| Reads responses? | No, the attacker can't see the result | Yes, it can read page content |
| Main defense | Anti-CSRF tokens, SameSite cookies | Output escaping, CSP |
One important connection: if your site has an XSS vulnerability, an attacker can usually bypass CSRF protection, because their script runs on your origin and can read tokens from the page. CSRF defenses assume you've also prevented XSS.
How to Prevent CSRF
1. Use Anti-CSRF Tokens
The most important defense is a token that's unique to the user's session (and ideally to the action), included in every state-changing request, and verified on the server. An attacker's page can't read the token from your site, so it can't forge a valid request.
CSRF Protection in WordPress with Nonces
WordPress calls its tokens nonces. Despite the name, WordPress nonces can be used more than once within their lifetime (typically 12 to 24 hours), but they're tied to the user, the action, and the session, which is what CSRF protection requires.
Here's an admin form with a nonce, a capability check, and sanitization. You could place this in a custom plugin:
<?php
/**
* Plugin Name: Sajjad Settings Example
*/
defined( 'ABSPATH' ) || exit;
add_action( 'admin_menu', function () {
add_options_page(
'Sajjad Settings',
'Sajjad Settings',
'manage_options',
'sajjad-settings',
'sajjad_render_settings_page'
);
} );
function sajjad_render_settings_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
?>
<div class="wrap">
<h1><?php esc_html_e( 'Sajjad Settings', 'sajjad' ); ?></h1>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="sajjad_save_settings">
<?php wp_nonce_field( 'sajjad_save_settings', 'sajjad_nonce' ); ?>
<label for="sajjad_notice"><?php esc_html_e( 'Notice text', 'sajjad' ); ?></label>
<input type="text" id="sajjad_notice" name="sajjad_notice"
value="<?php echo esc_attr( get_option( 'sajjad_notice', '' ) ); ?>">
<?php submit_button(); ?>
</form>
</div>
<?php
}
add_action( 'admin_post_sajjad_save_settings', function () {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You are not allowed to do this.', 'sajjad' ), 403 );
}
check_admin_referer( 'sajjad_save_settings', 'sajjad_nonce' );
$notice = sanitize_text_field( wp_unslash( $_POST['sajjad_notice'] ?? '' ) );
update_option( 'sajjad_notice', $notice );
wp_safe_redirect( add_query_arg( 'updated', '1', admin_url( 'options-general.php?page=sajjad-settings' ) ) );
exit;
} );
The key pieces are wp_nonce_field() to add the token and check_admin_referer() to verify it. If the nonce is missing or invalid, WordPress stops the request.
Note that a nonce is not a permission check. Always pair it with current_user_can(). The nonce proves the request came from your form; the capability check proves the user is allowed to do it.
Nonces in WordPress AJAX
For admin AJAX requests, create a nonce, pass it to JavaScript, and verify it with check_ajax_referer():
add_action( 'admin_enqueue_scripts', function () {
wp_enqueue_script( 'sajjad-admin', plugins_url( 'admin.js', __FILE__ ), array(), '1.0.0', true );
wp_localize_script( 'sajjad-admin', 'sajjadAjax', array(
'url' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'sajjad_toggle_feature' ),
) );
} );
add_action( 'wp_ajax_sajjad_toggle_feature', function () {
check_ajax_referer( 'sajjad_toggle_feature', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => 'Forbidden' ), 403 );
}
$enabled = ! empty( $_POST['enabled'] );
update_option( 'sajjad_feature_enabled', $enabled ? 1 : 0 );
wp_send_json_success( array( 'enabled' => $enabled ) );
} );
And in admin.js:
async function toggleFeature(enabled) {
const body = new URLSearchParams({
action: "sajjad_toggle_feature",
nonce: window.sajjadAjax.nonce,
enabled: enabled ? "1" : "",
});
const response = await fetch(window.sajjadAjax.url, {
method: "POST",
credentials: "same-origin",
body,
});
return response.json();
}
Nonces with the WordPress REST API
When the REST API is used with cookie authentication, such as from the block editor or your own admin scripts, WordPress requires a nonce created with the action wp_rest, sent in the X-WP-Nonce header. Without it, requests are treated as unauthenticated:
await fetch("/wp-json/sajjad/v1/settings", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-WP-Nonce": window.sajjadApp.nonce,
},
credentials: "same-origin",
body: JSON.stringify({ value: "hello" }),
});
If you use @wordpress/api-fetch, it handles this for you once the nonce middleware is configured. Remember to also set a proper permission_callback on your REST routes.
CSRF Tokens in Plain PHP
Outside WordPress, generate a random token per session, include it in forms, and compare it with a timing-safe function:
session_start();
if ( empty( $_SESSION['csrf_token'] ) ) {
$_SESSION['csrf_token'] = bin2hex( random_bytes( 32 ) );
}
if ( $_SERVER['REQUEST_METHOD'] === 'POST' ) {
$token = $_POST['csrf_token'] ?? '';
if ( ! is_string( $token ) || ! hash_equals( $_SESSION['csrf_token'], $token ) ) {
http_response_code( 403 );
exit( 'Invalid CSRF token.' );
}
// Process the form safely here.
}
?>
<form method="post">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars( $_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8' ); ?>">
<input type="email" name="email" required>
<button type="submit">Update email</button>
</form>
Most frameworks, including Laravel, Symfony, Django, and Rails, include CSRF protection by default. Make sure you haven't disabled it.
2. Set SameSite Cookies
The SameSite cookie attribute tells browsers when to send cookies on cross-site requests:
Strict: Never sent on cross-site requests. Strongest, but users following a link to your site won't appear logged in on that first request.Lax: Sent on top-level navigations using safe methods like GET, but not on cross-site POSTs, iframes, or background requests. Chromium-based browsers useLaxas the default when no value is set, though not every browser does.None: Sent on all requests, but must be paired withSecure. Needed for some embedded or cross-site scenarios.
In Node.js with Express, you can set this on your session cookie:
import express from "express";
import session from "express-session";
const app = express();
app.use(
session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: "lax",
},
}),
);
SameSite is a valuable layer, but don't rely on it alone. Older browsers, subdomain attacks, and GET-based state changes can still be exploited.
3. Never Change State with GET Requests
GET requests should only read data. If a link like /delete-post?id=5 deletes something, an attacker can trigger it with a simple image tag, and SameSite=Lax won't stop top-level GET navigations. Use POST, PUT, PATCH, or DELETE for changes, and still require a token.
In WordPress, when you do need an action link (such as "Delete" in an admin table), protect it with wp_nonce_url() and verify with check_admin_referer().
4. Check Origin and Referer Headers
As an additional layer, verify that the Origin (or Referer) header on state-changing requests matches your own site. Modern browsers also send Sec-Fetch-Site, which tells you whether a request is same-origin, same-site, or cross-site. Rejecting cross-site requests to sensitive endpoints blocks many CSRF attempts.
5. Require Re-Authentication for Critical Actions
For high-risk actions like changing a password, email, or payment details, ask for the current password or a 2FA code. Even if a CSRF attack slipped through, the attacker wouldn't know the required secret.
6. Keep Plugins Updated
As a WordPress site owner, most CSRF risk comes from third-party plugins with missing nonce checks. Keep plugins updated, remove unused ones, and consider a WAF for virtual patching of known issues.
FAQ: Cross-Site Request Forgery (CSRF)
CSRF is when a malicious website secretly makes your browser send a request to another site you're logged into, such as your WordPress dashboard, so that site performs an action you never asked for.
A WordPress nonce is a token tied to a specific user, action, and time window. It's added to forms, links, and AJAX requests, and WordPress checks it to confirm the request came from your site rather than from an attacker.
They block many CSRF attacks, especially with Lax or Strict settings, but not all. GET requests that change state, same-site subdomains, and older browsers can still be abused, so tokens remain essential.
Generally no. CSRF sends requests but can't read the responses. However, it can change settings, such as your email address, which may then let the attacker take over your account.
APIs that authenticate with cookies need CSRF protection. APIs that require a token in a custom header, like a bearer token that isn't stored in a cookie, are generally not vulnerable because browsers don't attach those automatically.
No. A nonce confirms the request was intended, but you still need a capability check with current_user_can to confirm the user is allowed to perform the action, plus sanitization of any input.
Conclusion
Cross-site request forgery abuses the fact that browsers automatically send cookies with every request. An attacker's page can quietly make a logged-in user's browser submit forms or requests to your site, and without protection your site can't tell the difference. The result can be anything from a changed email address to a brand-new administrator account.
The defense is straightforward: require an unpredictable token on every state-changing request (nonces in WordPress, framework tokens elsewhere), combine it with capability checks, set SameSite cookies, never change data with GET requests, and re-authenticate for critical actions. Prevent XSS too, since it can undermine every CSRF defense, and keep third-party plugins updated so a missing nonce check in someone else's code doesn't become your problem.


