WordPress REST API 401: Authenticate Correctly
Send a nonce or Application Password — WordPress REST 401s come from missing auth, absent permission_callback, or blocking plugins..
20+ years shipping production backend systems. Notes here come from systems that actually shipped.
- ✓Admin access plus ability to edit theme or plugin code
- ✓Browser devtools basics for inspecting request headers
- ✓HTTPS enabled (required for Application Passwords)
- 401 means no usable credentials arrived — send the X-WP-Nonce header for cookie auth
- Cross-site or server-to-server calls need Application Passwords, not cookies
- Every custom route needs permission_callback or it fails closed by default
- Security and CORS plugins can strip auth headers before WordPress sees them
- Debug with devtools and curl: confirm headers leave, arrive, and validate in turn
Think of the REST API as a members-only counter. Showing up (being logged in) isn't enough — you must present your badge at the counter. Browser requests show the badge via a nonce header; outside apps use an Application Password instead. A 401 means you reached the counter but never showed a badge, or the bouncer (permission_callback) found no instructions for you.
Your JavaScript fetch to /wp-json/ returns 401 Unauthorized — even though you're logged into WordPress in the same browser. Or your external integration's requests fail while the browser works fine. The endpoint exists, the user exists, but WordPress refuses to believe you're you.
The 401 is WordPress saying no usable authentication arrived with the request. Logged-in browsers need a fresh nonce header. Server-to-server code needs Application Passwords or another bearer scheme. Custom endpoints additionally need a permission_callback telling WordPress who's allowed — without one, routes fail closed. And sometimes credentials are fine but a security plugin strips them first.
This guide fixes all four. You'll send nonces correctly from JavaScript, build Application Password integrations, write permission callbacks that fail safe, and detect plugins and CORS setups that eat auth headers. By the end, every 401 maps to one layer you can verify directly. The examples use vanilla WordPress with no extra auth plugins — add-ons like JWT or OAuth change the header names but never the layered debugging order.
Decide Which Auth Path Your Caller Needs
Same-site browser JavaScript uses cookie auth: the session cookie proves login, and the X-WP-Nonce header proves the request originated on your site. Both must arrive together — cookie without nonce fails, nonce without cookie fails. WordPress localizes the nonce into your script via wp_localize_script so each page load carries a fresh one.
Everything else — mobile apps, external services, cron scripts, cross-domain front ends — uses Application Passwords. These 24-character credentials pair with the username over HTTP Basic auth and carry only the issuing user's capabilities. They're revocable per integration, so one compromised key doesn't endanger the account.
Picking wrong is the top 401 source. Server scripts can't use cookie auth (no browser session), and external origins can't use nonces bound to another site's login. Headless front ends on the same domain can use either, but nonces expire with page caches — many headless builds standardize on Application Passwords to dodge staleness. Match the path to the caller first; half of all 401 tickets end right here with no code change needed. Sketch the caller on paper — browser tab, mobile app, or cron server — and the correct path reads itself off the sketch.
Send the Nonce Header From JavaScript
The nonce ties the request to the login session and the wp_rest action. Generate it server-side with wp_create_nonce('wp_rest'), pass it to your script with wp_localize_script, and attach it as the X-WP-Nonce header on every mutating fetch. WordPress validates header plus cookie together on each REST request.
Two caching traps break this. Full-page caches can serve stale nonces — a cached page carries yesterday's nonce, which fails validation today. Exclude nonce-bearing pages from cache or refresh the nonce via a lightweight uncached endpoint. Second, nonces expire with sessions: a user logged in for 12 hours holds a nonce tied to a rotated session token, so long-lived tabs should re-fetch the nonce on 401 and retry once.
Verify in devtools: the failing request must show both the Cookie and X-WP-Nonce headers. Admin-ajax calls use a different action name (not wp_rest), so don't reuse those nonces on REST routes — mismatched actions fail validation. Missing header means your script dropped it (the checkout incident exactly); present-but-rejected means staleness or session rotation. Log the validation result server-side during debugging to split the two conclusively. Keep one staging page uncached for nonce tests; cached fixtures hide the staleness you are hunting.
Authenticate Servers With Application Passwords
Application Passwords live under Users > Profile > Application Passwords. Create one per integration with a clear name (warehouse-sync, uptime-monitor), and WordPress shows the 24-character secret once — store it in your vault immediately. The integration sends username plus secret as HTTP Basic auth over HTTPS on every request.
Scope follows the user: the key can do exactly what the issuing account can, no more. Issue keys from a dedicated editor-or-lower service account, never an admin, so a leaked key can't reconfigure the site. Revocation is one click on the profile screen — rotate keys on staff changes and after any incident.
Test with curl before wiring the integration: a 200 with your username in the response proves the credential path end to end. Name keys after the system and owner (warehouse-sync-maria) so revocation audits don't guess. If curl 401s while the password is certainly right, the Authorization header is being stripped upstream — a host or plugin issue covered below, not a wrong password. Always require HTTPS; Basic auth over http leaks the secret in transit. Rotate integration secrets on staff departures; shared keys outlive team changes silently, and the audit log should name which key made each request.
Write permission_callback So Routes Fail Safe
Every register_rest_route call needs permission_callback — it answers whether the current request may proceed. Return current_user_can checks matched to the action: edit_posts for content writes, manage_options for settings. Public read routes may return __return_true, but write routes must never do that. Missing callbacks fail closed on modern WordPress, producing 401/403s that look like broken auth.
Debug callbacks by splitting auth from authorization. First call the route with an admin Application Password: success proves credentials arrive and indicts the callback as too strict. Log the current user ID and capabilities inside the callback during staging tests — anonymous (ID 0) means auth never ran, while a logged-in ID with false caps means the role mapping is wrong. Re-test after every role-editor change; capability plugins rewrite the map your callback trusts.
Old tutorials omit permission_callback entirely, and copied code inherits the hole. Audit every custom route at release: each gets an explicit callback, each callback gets a test with an under-privileged user asserting denial and an authorized user asserting success. Fail-safe defaults turn future auth mistakes into clean denials instead of silent data exposure.
Find Plugins and Hosts That Strip Auth
Credentials can leave correctly and never arrive. Security plugins routinely disable REST for lower roles, block unknown namespaces, or strip Authorization headers as hardening. One-by-one deactivation with a curl retest after each identifies the blocker in minutes — start with security, firewall, and coming-soon plugins.
Hosts cause the subtler variant: Apache and Nginx configs that don't forward HTTP_AUTHORIZATION to PHP. The symptom is precise — Basic-auth curl 401s with a known-good Application Password while cookie browser calls work. The fix is a server rule preserving the header (RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] on Apache, fastcgi_param on Nginx) or a host-panel toggle.
Also inspect must-use plugins and theme functions for rest_authentication_errors filters that blanket-deny. Multisite adds a wrinkle: rest_cookie_auth can behave per-subsite, so test the exact subsite URL the caller hits rather than the main domain. Snapshot the active plugin list before bisecting; restoring exact state beats memory. Log the filter's input during debugging to see who's denying whom. Document the culprit once found — these reappear after plugin reinstalls and host migrations because the setting, not the code, is the cause.
Fix CORS So Browsers Send Credentials
Cross-origin front ends (app.yoursite.com calling api.yoursite.com, or localhost during development) face preflight checks before the real request. The browser sends OPTIONS asking permission; if the response lacks your origin or the needed headers, the authenticated call never fires — and the fallout looks like a 401 downstream. The Network tab shows the OPTIONS failure plainly.
The fix lives server-side: respond to OPTIONS with Access-Control-Allow-Origin matching your front end (never * when credentials ride along), plus Access-Control-Allow-Headers including Authorization and X-WP-Nonce, and Allow-Credentials true. Dedicated CORS plugins or a few server lines do this; hand-rolled theme hacks usually miss the OPTIONS short-circuit and the preflight dies in WordPress's 404 handling.
Keep localhost origins in dev config only, and enumerate production origins explicitly. Safari's Intelligent Tracking Prevention adds its own cookie quirks on cross-site calls — test failure cases in every browser your users touch, not just Chrome. Document the approved origin list in the repo; the next frontend domain gets added deliberately. Wildcard origins with credentials are rejected by browsers on principle — that's a spec rule, not a WordPress quirk, and no plugin can override it.
Checkout App Lost 210 Orders to a Missing Nonce
- Any front-end refactor touching fetch calls needs an auth-header assertion in tests. One dropped header cost 210 orders because no test checked headers.
- Guest-vs-member failure splits scream authentication context. When only logged-in users fail, inspect the nonce/cookie path before WAFs and caches.
- Synthetic logged-in probes catch auth regressions in minutes. A 5-minute checkout probe would have cut this from 5 hours to 5 minutes.
| File | Command / Code | Purpose |
|---|---|---|
| enqueue-nonce.php | wp_enqueue_script( 'shop-checkout', get_template_directory_uri() . '/checkout.js... | Send the Nonce Header From JavaScript |
| curl -s -u 'sync_user:abcd EFGH ijkl MNOP qrst uvwx' \ | Authenticate Servers With Application Passwords | |
| routes.php | register_rest_route( 'shop/v1', '/cart', array( | Write permission_callback So Routes Fail Safe |
| server-fetch.php | $response = wp_remote_post( rest_url( 'shop/v1/cart' ), array( | Fix CORS So Browsers Send Credentials |
Key takeaways
Common mistakes to avoid
5 patternsSending cookies from server-to-server code
Caching pages that carry fresh nonces
Copying route code without permission_callback
Using admin accounts for integrations
Blaming code for environment interference
Interview Questions on This Topic
What does a WordPress REST 401 mean versus a 403?
Frequently Asked Questions
20+ years shipping production backend systems. Notes here come from systems that actually shipped.
That's WordPress. Mark it forged?
5 min read · try the examples if you haven't