Nonces

This article is part of the WordPress guide. Read the introduction.

Nonces are used to prevent CSRF attacks. Let’s start with a more general explanation of a nonce, and we’ll get to the WordPress implementation later.

The name “nonce” comes from “number used once“. It’s supposed to be a random (or pseudo-random) one-time token used to verify a request. In its general form, nonces aren’t tied to CSRF attacks only. They are a cryptographic tool used in many ways, such as preventing replay attacks or verifying data freshness.

Let’s say you’re rendering the HTML for an admin settings page (not in WordPress). You should create and include a nonce in a hidden input field for every form on that website. This way, when the user submits the form, you can check this nonce and compare it with the one stored in the database (you have to store the nonce after generating it).

If they match, it means that the form submission actually originated from the admin page and not from an attacker’s website (i.e., a CSRF attack).

WordPress’s Nonce Implementation

The explanation above was a quick summary of how “pure” cryptographic nonces work. WordPress’s nonces are far from it. As a matter of fact, WordPress’s nonce implementation is so unlike pure nonces that it’s a common point of confusion. That’s not to say it’s inherently worse – it is not.

WordPress nonces are not used once. They are valid for a specified amount of time. They are also not numbers – they are md5 hashes. They don’t get stored in the database – they are recomputed on every request.

That’s cool, but you probably have no idea what any of that means. To truly understand it, we have to start with the “Why”. WordPress was designed to be as stateless as possible. You should remember that from the chapter on user accounts. There is no session – the user is verified via the client-stored cookie.

A pure nonce implementation requires a lot of session-related architecture. Most importantly, nonces have to be stored in the database in order to be verified. If it’s truly a random number, you can’t reliably recompute it on the POST request. You have to save it in the database when it’s created and then read it from the database to compare it with the nonce passed in the request.

This is a source of many problems. Let’s say you had an admin page with 50 “delete” links. Every single refresh of that page would create 50 new rows in the database. That’s a lot of additional operations and bloat in the database. You’d also probably need a new table specifically for nonces.

Every nonce would need some sort of expiration, but what happens if a user opens the same page in two tabs? Does the nonce from the older tab get invalidated or not? What if the user submits the form in tab 2 and then tries to submit it again in tab 1? What about back buttons? What about caching pages and serving them as static HTML? All of those are very painful problems when using pure nonces.

WordPress solved these problems by opting for a stateless implementation. Here’s what happens, step by step, when you create a new nonce using wp_create_nonce( $action ):

  1. WordPress gets the ID of the current user.
  2. WordPress gets the current session token (passed in the user cookie).
  3. WordPress gets the current tick for the action (we’ll cover that soon).
  4. WordPress creates a concatenated string: $tick . ‘|’ . $action . ‘|’ . $uid . ‘|’ . $token
  5. WordPress passes this string to wp_hash() with the “nonce” scheme and the default md5 algorithm.
  6. WordPress returns a substring (10 characters) of the calculated hash.

The $action is just a hard-coded string. It’s supposed to be different for every type of action. You can think of it as the nonce’s name. The action of a nonce for a form saving some plugin settings might be “pgn_settings”. We’ll talk more about it later.

The “tick” is probably the most interesting part of this equation. It’s also the hardest to explain. A WordPress nonce is valid for some amount of time. By default, the lifetime of a nonce is up to 24 hours. The current tick for the nonce is calculated using the wp_nonce_tick() function. Here’s its source code:

PHP
$nonce_life = apply_filters( 'nonce_life', DAY_IN_SECONDS, $action );
return ceil( time() / ( $nonce_life / 2 ) );

time() returns the current Unix timestamp, e.g., “1757597729”. It is then divided by half of the nonce’s lifetime in seconds. Finally, it’s rounded up. Let’s visualize what this does on smaller numbers. Let’s assume the current Unix timestamp is 21 and our $nonce_life is 10 seconds.

ceil( 21 / (10 / 2) ) = ceil( 21 / 5 ) = 4. The current tick is 4. Another second passes, ceil( 22 / 5 ) = 4. The tick is still 4. It’s going to be 4 for the next 2 seconds, when time() is 23 and 24. The moment the Unix timestamp becomes 25, the tick becomes equal to 5. What this means is the nonce, which uses this tick for hashing, is going to become different (the concatenated string passed to wp_hash() will be different).

Now comes the most important part – verification. When you verify the validity of a nonce using wp_verify_nonce(), this function computes the hash for both the current tick (5) and the previous tick (4). That is why we’re dividing the current Unix timestamp by only a half of the nonce’s lifetime.

Why? Imagine what would happen if we requested a page with a form precisely when the Unix timestamp was 28. We’d get a nonce generated for the tick “4”. We then submit this form a few seconds later, when the timestamp is 32. If wp_verify_nonce() only checked the current tick, our submission would be rejected! Imagine the mayhem if every form you loaded on the edge of the tick became invalid the second the next tick started.

Also, notice that our nonce was not valid for 10 seconds. It was valid for 9 seconds, because it first got generated at timestamp 21, not 20. That’s why I said that, by default, a nonce is valid for up to 24 hours. In reality, it’s between 12 and 24 hours – the length of 2 ticks (where the length of the first one can be shorter than 12 hours).

Don’t feel stupid if you still don’t understand it. It really is an advanced concept. Let’s summarize it:

  • When WordPress generates a nonce, it adds a tick to the hashed string.
  • A tick is just an integer. It increases by 1 every time the rounded up result of (unix timestamp / half of the lifetime) increases. This means that if the lifetime of a nonce is 10 seconds, the tick will increase by one every 5 seconds (when the timestamp is 20, 25, 30, 35, etc.).
  • This increase of the tick produces a different md5 hash every half of the lifetime of the nonce. That means the nonce becomes different.
  • When verifying the nonce, WordPress computes its version both for the current tick and the previous tick.
  • If WordPress only checked one nonce, generated with a tick of the full lifetime (10 seconds), all the nonces would become invalid the very second the result of (Unix timestamp / tick in seconds) changed. This would mean that if you refreshed the page when the timestamp was 29 and submitted the form 2 seconds later – when it was 31, the nonce would be invalid (even though only 2 seconds have passed). Instead, using the half-life of the nonce, its real lifetime is at least a half of the configured value.

This tick system eliminates the need for storing the expiration time of the nonce and storing the nonce in the database. Instead, the nonce is dynamically computed on every request, and its value automatically changes every half of the configured nonce’s lifetime. Brilliant.

As for wp_hash() – we’ve already covered it when discussing user accounts and sessions. The “nonce” scheme means it uses the NONCE_KEY and NONCE_SALT keys from wp-config.php when computing the hash. These are the secret keys used to make the nonces secure. Without them, anybody would be able to compute the correct nonce knowing a user’s ID, their session token, and the action (name) of the nonce.

To summarize: WordPress nonces are not real nonces, because they aren’t used only once. As such, they don’t protect you from replay attacks or other attacks the way pure nonces might. Their only job is to protect you from CSRF attacks. This is the only reason they exist. You should not use them for authorization or authentication. Always assume nonces can be compromised.

Creating Nonces

You now know the theory behind nonces. It’s time you learn how to use them in practice. A nonce needs to somehow be included in the action you want to protect from CSRF attacks.

There are 3 functions used to create and/or output nonces:

  • wp_create_nonce()
  • wp_nonce_field()
  • wp_nonce_url()

You already know wp_create_nonce( $action ). It’s the baseline function used for creating a nonce. It is used by the other 2 functions. It doesn’t echo anything, it returns the nonce, like “bdf297b994”.

The $action parameter, as we already discussed, is the unique name of the action the nonce is supposed to protect. You should strive to make it unique and specific. If you used the same action “delete_post” for all of your “delete” buttons, the nonce for deleting all posts would be the same. Compromising this one nonce would allow an attacker to delete all of your posts. It’s better to make the action a dynamic string “delete_post_$id”. This way, the nonce will be different for all your delete buttons.

wp_nonce_field() does the heavy lifting for you when you want to output the nonce in a form element. It accepts the action and the name of the input field, and echoes the field’s HTML markup. By default, it also includes a field containing the referer, which is just the current request’s URL (we’ll talk more about it when covering verification). Calling:

PHP
wp_nonce_field( 'pgn_action', 'pgn_nonce_name' );

on the /wp-admin/options-general.php page results in this HTML markup rendered (the nonce is obviously going to be different):

HTML
<input type="hidden" id="pgn_nonce_name" name="pgn_nonce_name" value="d1195fa9c3">
<input type="hidden" name="_wp_http_referer" value="/wp-admin/options-general.php">

wp_nonce_url() adds a nonce as a query arg in the passed URL. It’s for actions taken on GET requests (clicking a link) instead of form submissions, such as the aforementioned “delete” button (which would probably just be a link). Calling:

PHP
$url_with_nonce = wp_nonce_url( 'https://example.com/?query=1', 'pgn_action' );

will result in $url_with_nonce being equal to: https://example.com/?query=1&amp;_wpnonce=d1195fa9c3.

The function adds the query string to the URL using add_query_arg() (a pretty useful wp function). If you’re attentive, you might be wondering about something. Why is the ampersand (&) encoded? Well, that’s because wp_nonce_url() calls esc_html() on the URL just before returning it.

That’s right – a function responsible just for adding a nonce to a URL, escapes said URL before returning it. Not only is the concept itself stupid, but it doesn’t even use the correct escaping function, which is esc_url(). Ticket #20771 on Make WordPress Core from 2012 sheds some light onto this blunder. It seems to have been kept for backward compatibility. The bottom line is – use esc_url() on the URL returned by this function, even if the documentation states that it “returns an escaped URL”.

Verifying Nonces

A nonce is useless if you don’t check it before performing the action (in the code which actually does the thing, e.g., deletes the post). There are 3 functions designed for verifying a nonce:

  • wp_verify_nonce()
  • check_admin_referer()
  • check_ajax_referer()

wp_verify_nonce() is the baseline function which recomputes the nonce and compares it with the one passed as an argument. It’s used internally by the other functions. You can use it directly if you want to decide what happens when the nonce is incorrect.

check_admin_referer() is perhaps the most inaccurately named function in the entire WordPress core, and you’ll see why I say that very soon. At its core, all this function does is it checks the nonce and calls die() if it’s invalid.

It looks for the nonce in $_REQUEST[$query_arg], where $query_arg defaults to “_wpnonce” (the default name for wp_nonce_field() and default query args key for wp_nonce_url()). If you used a different name, like our “pgn_nonce_name” for wp_nonce_field(), you have to pass it as an argument. It works because the $_REQUEST superglobal contains values from both GET and POST requests (URLs and form submissions).

Here’s the thing – you can use this function in code running on the frontend. It is not limited to the admin panel. As for the “referer” part – it’s not even used in most cases. To understand where the name comes from, we have to dive into the genesis of this function.

check_admin_referer() was added in WordPress 1.2. Nonces, along with all the nonce-related functions, were added in WordPress 2.0.3. This function is older than nonces. Before nonces, what it did was it checked the referer header and called die() if the referer was not a URL inside /wp-admin. That is where the name comes from.

Right now, it only checks the referer in a very specific circumstance – when the nonce verification failed and the $action argument was not passed. If that’s the case, and the referer isn’t in /wp-admin, the function will call die(), just like its old version did. It’s a backward compatibility quirk. By the way, the referer value now prioritizes the _wp_http_referer field you saw earlier (rendered by default when using wp_nonce_field).

Why they didn’t create a new function with a more fitting name after introducing nonces is beyond me. It is one of the most confusing functions in WordPress precisely because of its bad naming. In its current form and in most cases – it has nothing to do with admin, nor with a referer. It just verifies the nonce and dies if it’s invalid.

The dying part is true, except for one very specific edge case introduced by the backward compatibility check. See, if the nonce is invalid and $action was not passed, but the referer is indeed /wp-admin, then the function will not call die(). Instead, it will return the result of the nonce check, which is false.

This might be very confusing, as most people in its current form view it the way I’ve just explained it – it calls die() if the nonce is invalid. Yet here we are, with the nonce being invalid, and the function not terminating the execution. If you don’t know about this quirk and you don’t pass the $action on an admin script – you will introduce a CSRF vulnerability.

How do you protect yourself against that? Pass the bloody $action! Not passing the $action is obsolete since WordPress 3.2. If you specify the $action (the nonce’s name), as is the only accepted modern practice, you are safe and you can ignore the last few paragraphs. Still, it’s an interesting “deep dive” fun fact.

check_ajax_referer() is pretty similar to check_admin_referer(). It differs from the latter by checking for the name “_ajax_nonce” in $_REQUEST before checking for “_wpnonce” (assuming the name is not explicitly passed). It also dies when the nonce is invalid.

Unlike check_admin_referer(), this function has no backward compatibility quirk for checking the referer. It doesn’t do anything with a referer at all, which makes its name another misnomer. It was probably named that way to keep consistency over correctness.

This function is supposed to be used when verifying nonces for AJAX requests. That’s its semantic meaning, and that’s where you should use it. We’ve not yet covered AJAX, so there’s not much to talk about right now. I might or might not mention it again when discussing AJAX later.

Nonces For Non Logged-In Users

Think back to the way nonces are computed. The list of all variables used to calculate the hash is: action, tick, user ID, session token, and secret keys. The action, tick, and secret keys are the exact same for all users. What makes nonces unique for different users are the user ID and the token.

But what happens if a user is not logged in? Their user ID is 0 and the token is an empty string. Suddenly, all of the variables are the same for all logged out users. This is a fundamental problem with WordPress nonces. They do not protect anonymous users from CSRF attacks.

An attacker can simply scrape your website while logged out, and they will get the exact same nonce every other logged out user gets. This is why you should be very careful when adding actions on your website that do not require the user to be logged in.

Thankfully, that’s not a typical case. I mean, what can an anonymous user really do? In most cases, the most “invasive” action they can take is send a publicly visible contact form. That’s not a CSRF target. I can’t really come up with any example of anonymous actions worth targeting with CSRF. Maybe if you tried to create some proprietary user sessions system outside of WordPress’s API, but that’s stupid and you should know that already.

Anyway, if you ever do find this behavior problematic, I have good news – you can change it. The filter “nonce_user_logged_out” is run for the user ID when it’s 0. Using this filter, you can modify the user ID, making the nonces be different for logged out users.

Nonces With Caching

Nonces are perhaps the biggest pain in the ass when you try to introduce caching (especially full-page caching) on your website. Full-page caching is when you create a static HTML file for a page and serve it instead of running the PHP code on every request.

What happens if a cached page contains something with a nonce? Well, the nonce can get stale and the functionality will break. Let’s assume you’re using the default lifetime of a nonce – 24 hours. You have a form on the frontend that uses a nonce. You set up a caching plugin with no expiration of the cache. After (up to) 24 hours, your form will not work for any user, because the nonce on the page will not match the nonce recomputed on form submission.

How do you solve this? There are two answers to this question – the ideal way and the realistic way. We’ll start with the latter.

In reality, most plugins and websites solve this by stepping over it. What I mean is: first, they don’t include nonces for publicly available actions. That’s the public contact form on the frontend from the previous section.

This form really doesn’t need a nonce. What are you protecting the form against? A CSRF attack on a public form the attacker could just submit themselves? It doesn’t need a nonce! The only real vulnerability is impersonating a sender by submitting the form using their IP.

The second part of this puzzle is disabling caching completely for logged in users. That’s the “stepping over it” part. Most caching plugins, by default, do not serve the cached version to logged in users. This way, if the page uses a nonce, it will be computed on every request.

To be fair – not caching and/or serving cached pages to logged in users is not only due to nonces. It’s a multi-factor problem with potential for user data poisoning the cache or leaking entirely. Some plugins allow you to keep a separate cache set for each user, but most still default to not caching logged in users at all.

This approach is not flawless. Imagine what would happen if you installed a badly written plugin that adds a form to the frontend. This form is completely public, but the plugin’s author for some reason decided to use a nonce. Your caching plugin caches this page and serves the cache to all anonymous users. The nonce will eventually become stale and the functionality provided by the plugin will not work. Your only hope is setting the cache’s expiration to the half-life of the nonce.

A more professional approach is fetching the nonce dynamically. You could create an AJAX/REST API endpoint on your website that returns a nonce. Then, instead of including the nonce in the markup generated by PHP, you just enqueue a JS script that retrieves the nonce from this endpoint and injects it into the HTML. This way, the cached HTML never sees the nonce.

This is an advanced technique, and I personally have not yet had the need to use it. I can’t really say more about it than what I’ve already said.

I suppose there’s a case to be made for a third, hybrid approach. If you had granular control over your cache rules and knew exactly which pages on your website did and did not contain nonces, you could set your caching up so that only the pages without nonces get cached. That approach could work, and can even allow you to cache some pages for logged in users, but it’s rather brittle and likely not good in 99% of cases.