wordpress-jwt-auth

JWT Auth

A WordPress plugin that completely replaces native authentication with an external JWT provider. All login attempts are redirected to the provider; users are created on demand, with the role the site already gives new users. No admin UI — configured entirely via wp-config.php.

Password login is refused on every path WordPress exposes. Set JWT_AUTH_EXCLUSIVE to remove the forms that ask for one too — on a WooCommerce store, that switch also closes the flows WooCommerce uses to hand out a session without consulting the provider at all.

Supports two modes:

Mode When to use Examples
OIDC WordPress redirects users to the provider for login Zitadel, Keycloak, Auth0, Okta
Proxy An upstream proxy injects a signed JWT into every request Cloudflare Zero Trust, Authentik, Traefik Forward Auth

Requirements


Installation

  1. Download jwt-auth.zip from the latest GitHub Release
  2. In WordPress, go to Plugins → Add New → Upload Plugin and upload the zip
  3. Configure the required constants in wp-config.php (see Configuration section)
  4. Activate the plugin through the WordPress admin interface

The release zip bundles all Composer dependencies, so there is no separate composer install step. Once installed, the plugin checks GitHub for new releases and offers one-click updates through the normal WordPress Plugins screen.

From source (development)

# 1. Place this directory inside wp-content/plugins/jwt-auth/
# 2. Install dependencies
composer install --no-dev --optimize-autoloader

# 3. Activate the plugin in wp-admin, or via WP-CLI:
wp plugin activate jwt-auth

A Nix devshell with PHP 8.4 and Composer is provided:

nix develop

Configuration

All configuration is done via constants in wp-config.php. The plugin does nothing — and leaves WordPress fully functional — until at least one mode is configured.

OIDC mode (Zitadel, Keycloak, Auth0, …)

Define JWT_AUTH_CLIENT_ID to activate. Endpoints are auto-discovered from {issuer}/.well-known/openid-configuration.

define('JWT_AUTH_ISSUER',        'https://your.zitadel.cloud');
define('JWT_AUTH_CLIENT_ID',     'your-client-id@project');
define('JWT_AUTH_CLIENT_SECRET', ''); // leave empty for PKCE-only (recommended)

The plugin uses PKCE (S256) by default. Set JWT_AUTH_CLIENT_SECRET only if your provider requires a confidential client.

Zitadel setup checklist:

  1. Create a PKCE application in your Zitadel project.
  2. Set the allowed redirect URI to https://yoursite.com/?jwt_auth_callback=1.
  3. Set the post-logout redirect URI to https://yoursite.com/.
  4. Copy the issuer URL and client ID into wp-config.php.

Proxy mode (Cloudflare Zero Trust, Authentik, …)

Define JWT_AUTH_JWKS_URI without JWT_AUTH_CLIENT_ID to activate. The proxy must inject a signed JWT into every authenticated request before it reaches WordPress.

// Cloudflare Zero Trust
define('JWT_AUTH_ISSUER',       'https://yourteam.cloudflareaccess.com');
define('JWT_AUTH_JWKS_URI',     'https://yourteam.cloudflareaccess.com/cdn-cgi/access/certs');
define('JWT_AUTH_AUD',          'your-cf-audience-tag');
define('JWT_AUTH_TOKEN_COOKIE', 'CF_Authorization');

// Or use a header instead of a cookie:
// define('JWT_AUTH_TOKEN_HEADER', 'Cf-Access-Jwt-Assertion');

Cloudflare Zero Trust setup checklist:

  1. Add a Cloudflare Access application protecting your WordPress site.
  2. Copy the Audience Tag from the application settings into JWT_AUTH_AUD.
  3. Set your team domain in JWT_AUTH_ISSUER and JWT_AUTH_JWKS_URI as shown above.

Self-hosted email-PIN provider (Cloudflare Worker)

If you don’t want to run a full OIDC server, this repo ships a companion Cloudflare Worker that acts as an OIDC provider and authenticates users by emailing them a 6-digit PIN (plus a one-click magic link). It works with this plugin’s OIDC mode — no extra constants beyond the three below.

define('JWT_AUTH_ISSUER',        'https://auth.example.com'); // the worker's origin
define('JWT_AUTH_CLIENT_ID',     'yoursite');                  // this site's tenant id in the worker
define('JWT_AUTH_CLIENT_SECRET', ''); // PKCE-only public client (recommended)

New visitors who prove ownership of an email address get an account created automatically, as long as the site is accepting registrations.

One worker can serve many sites from a single issuer. Each site is a tenant identified by its JWT_AUTH_CLIENT_ID, which the worker uses as the token’s aud claim — so give every site a distinct value, since the audience check below is what keeps one site’s tokens from being accepted by another. A multi-tenant worker also offers cross-site single sign-on: verifying a PIN at one site signs the user in at the others (with a confirmation the first time they reach each new one).

The worker’s source lives in worker/ and is published to GitHub Packages as @avunu/jwt-auth-worker on each release. Deployments are managed from a private fleet repo that consumes the package. See worker/README.md for the tenant shape and the required Cloudflare setup (Email Sending domain onboarding, a Turnstile widget, and a custom domain).


All constants

Constant Default Description
JWT_AUTH_ISSUER — Provider base URL. Used for iss claim validation and OIDC discovery.
JWT_AUTH_CLIENT_ID — OIDC client ID. Presence of this constant activates OIDC mode.
JWT_AUTH_CLIENT_SECRET ’’ OIDC client secret. Leave empty for PKCE-only.
JWT_AUTH_JWKS_URI — JWKS endpoint URL. Required in proxy mode. Overrides OIDC-discovered URI when set in OIDC mode.
JWT_AUTH_AUD — Expected aud claim value. Required in proxy mode. Overrides client_id audience check in OIDC mode.
JWT_AUTH_TOKEN_COOKIE — Cookie name carrying the JWT (proxy mode).
JWT_AUTH_TOKEN_HEADER — HTTP header name carrying the JWT (proxy mode). Falls back to Authorization: Bearer if neither cookie nor header is configured.
JWT_AUTH_LOGOUT_URL — Provider logout URL. Overrides OIDC end_session_endpoint when set.
JWT_AUTH_REQUIRE_VERIFIED_EMAIL false Require email_verified: true before an email address may claim an existing WordPress account. See User creation.
JWT_AUTH_CLAIM_EMAIL email JWT claim containing the user’s email address.
JWT_AUTH_CLAIM_FIRST_NAME given_name JWT claim for first name.
JWT_AUTH_CLAIM_LAST_NAME family_name JWT claim for last name.
JWT_AUTH_CLAIM_NAME name JWT claim for display name.
JWT_AUTH_REDIRECT / Post-login redirect destination.
JWT_AUTH_PROVIDER_NAME SSO Provider label shown in the WooCommerce sign-in button.
JWT_AUTH_EXCLUSIVE false Remove the native password forms instead of standing beside them. See Exclusive mode.
JWT_AUTH_NATIVE_LOGIN by environment true: sign in with WordPress passwords and leave the provider out; false: never. Unset, this is decided by WP_ENVIRONMENT_TYPE. See Local development.

Behaviour

Authentication flow (OIDC mode)

  1. Any visit to wp-login.php immediately redirects to the provider’s authorization endpoint.
  2. The plugin generates a random state and a PKCE code_challenge (S256), stored server-side in WordPress transients. Nothing is written to cookies or the URL.
  3. After the user authenticates, the provider redirects to https://yoursite.com/?jwt_auth_callback=1&code=…&state=….
  4. The plugin validates the state, exchanges the code for tokens at the provider’s token endpoint, and validates the id_token JWT against the provider’s JWKS.
  5. A WordPress user is found (by sub meta, then email) or, if the site is accepting registrations, created with the site’s default role for new users.
  6. A standard WordPress auth cookie is set and the user is redirected to their original destination.

Authentication flow (proxy mode)

  1. The upstream proxy authenticates the user and injects a signed JWT into every request.
  2. On each unauthenticated WordPress request, the plugin reads the JWT from the configured cookie, header, or Authorization: Bearer.
  3. If the JWT is valid and the audience matches, the user is found — or created, if the site is accepting registrations — and a WordPress session is established for the current and all future requests.

User creation

New users are created with:

On every subsequent login, the user’s first name, last name, display name, and email are synced from the JWT claims. The sub meta is used for lookups first, so email changes at the provider are handled gracefully.

An existing account is adopted by email only when the provider has not said the address is unverified: a token carrying email_verified: false never claims an account that way. A token that omits the claim is accepted by default, because the companion worker never issues an unverified address and older tokens predate the claim. For a provider with self-service signup, set define('JWT_AUTH_REQUIRE_VERIFIED_EMAIL', true); to demand email_verified: true instead. Accounts already linked by sub are unaffected.

What new accounts can do

The role comes from WordPress’s own Settings → General → “New User Default Role” (default_role), the same setting every other registration path on the site obeys. There is no separate constant.

The plugin does not read the setting at all — wp_create_user() applies it, and the plugin deliberately declines to name a role afterwards. That is what makes the guarantee structural rather than vigilant: no provider claim participates in the decision, so no token, however crafted, can ask for a role. The answer is whatever the administrator chose for strangers.

Upgrading to 4.0.0: this replaces the JWT_AUTH_DEFAULT_ROLE constant, which is no longer read. If you set it, copy its value into Settings → General → “New User Default Role” before updating — otherwise accounts created after the update get whatever that setting already says, which on most installs is subscriber. Existing users are unaffected; only newly provisioned accounts take the role. Removing the define() from wp-config.php is optional, but it is now dead configuration.

Turning account creation off

Account creation follows WordPress’s own Settings → General → Membership → “Anyone can register” (users_can_register). There is no separate constant. Untick it and the plugin stops minting accounts for people the provider vouches for.

Upgrading to 3.0.0: WordPress ships this box unticked, and earlier versions of the plugin ignored it. If your site relies on just-in-time provisioning, tick it before or immediately after updating, or new visitors will be turned away.

What still works with the box unticked:

What a turned-away visitor sees:

Direct login is blocked

The authenticate WordPress filter returns WP_Error for all username/password attempts, including programmatic calls, XML-RPC, application passwords and WooCommerce checkout. WP-CLI and cron jobs are exempt.

The hook runs at priority 30, which is the part that makes this true rather than merely intended. authenticate is a filter, so the login is decided by whatever the last callback returns, and WordPress core registers its own handlers at priority 20. Registered below them — as this was until v3.0.1 — the refusal was produced and then discarded by core’s successful WP_User, leaving password login working on every path that does not pass through wp-login.php. If you fork this, do not “tidy” that number downwards; tests/Unit/AuthenticateFilterTest.php runs the real filter chain and pins both the fix and the original bug.

The forms, however, stay where they were. By default the plugin refuses credentials without removing the boxes that ask for them, so My Account still shows “Username or email address” above the SSO button and the checkout still offers “Returning customer? Click here to login”. Exclusive mode takes them away.

Exclusive mode

define('JWT_AUTH_EXCLUSIVE', true); makes the provider the only offer on the page rather than one of two.

Most of what it does is presentational, and honestly so: a password box on a site running this plugin is a control that cannot succeed, so removing it changes what a visitor is asked, not what the site accepts. Two of the things it closes are not presentational at all, and they are the reason the switch exists rather than a stylesheet. WooCommerce grants WordPress sessions on paths that never reach the authenticate filter:

What exclusive mode turns off:

Surface What happens instead
wp_login_form() — any theme or plugin call The fields are moved into an inert <template> and the sign-in button is rendered in their place.
Core’s Login/out block with “Display login as form” ticked Falls back to a plain link to wp-login.php, which is where the provider redirect lives.
wp-login.php sign-in, registration and password-reset screens A notice. In OIDC mode this is only reached when the provider could not be discovered, so it says so and returns 503; in proxy mode it explains that access is managed upstream.
Password reset, everywhere allow_password_reset is false, so no reset key is ever issued — by core, by WooCommerce, or by an administrator’s “Send password reset”.
WooCommerce My Account login and registration forms Replaced by the sign-in button.
WooCommerce checkout login prompt, and the global/form-login.php form behind the pay and order-received pages Replaced by the sign-in button, still gated on WooCommerce’s own “checkout login reminder” setting.
WooCommerce lost-password and reset-password screens Replaced by an explanation, and the four WC_Form_Handler hooks behind them are unhooked.
WooCommerce checkout and order-confirmation account creation Off (woocommerce_checkout_registration_enabled, woocommerce_enable_delayed_account_creation). New customers get an account on their first federated sign-in instead.
/wc-auth/v1/authorize — the app-authorisation login Replaced by the sign-in button, returning to the authorise step afterwards. WooCommerce only special-cases this screen for Jetpack SSO; every other provider got a password box.

What it deliberately leaves alone:

Two consequences worth knowing before you switch it on:

Nothing about the switch is a second line of defence for credentials: authenticate is still the boundary, and it is on whether or not this is set.

Local development

In a local or development environment (WP_ENVIRONMENT_TYPE), the plugin stands down: none of its hooks are registered, WordPress signs people in with passwords, and the login screen says so. The provider is normally unreachable from there anyway — the callback URL would be http://127.0.0.1:<port>/…, which no provider has registered — so intercepting the login screen could only lock the developer out.

define('JWT_AUTH_NATIVE_LOGIN', false); exercises the provider flow from a development environment regardless; define('JWT_AUTH_NATIVE_LOGIN', true); stands down anywhere, for a staging site without a provider. Every other constant is ignored while standing down, including JWT_AUTH_EXCLUSIVE.

WooCommerce

A “Sign in with SSO” button is added to WooCommerce login forms, in OIDC mode only. In proxy mode users are authenticated before the page renders, so there is nothing to sign in to.

The server does the work. woocommerce_login_form_start fires inside the classic login form in both templates WooCommerce renders one from — myaccount/form-login.php and global/form-login.php (reached from Checkout and the pay/order-received pages) — so My Account and Checkout are both covered, and the button still works with JavaScript switched off.

A small script (build/woo-login.js, built from assets/src/) covers only what a server-side hook cannot see: a login form added to the page after the response, by an AJAX-rendering theme or a login modal. It skips any form that already contains a button, so on an ordinary page it adds nothing at all.

Both injectors mark their wrapper .jwt-auth-sso, and that shared class is what keeps them from colliding. Before 3.0.1 the script tracked only its own work, and prepended a second button under every server-rendered one on My Account.

Under exclusive mode the same two injectors do the same two jobs, one step further: PHP substitutes the templates rather than decorating them, and the script replaces a late-rendered form’s contents rather than prepending to them. The marker guard is what makes the second safe — every form WooCommerce renders itself has already been swapped server-side and carries the class, so a form still standing when the script runs came from a theme or a modal, which is the one place a password field can survive the switch.


Security notes


Development

nix develop            # PHP 8.4 with the required extensions, Composer, PHPStan, Node
composer install
composer test          # PHPUnit
composer phpstan       # static analysis, level 8, WordPress-aware
composer check         # both

The plugin’s suite runs against a small in-memory fake of the WordPress functions it calls (tests/Support/functions.php) rather than a real WordPress install, so it needs no database and finishes in well under a second. wp_die() and wp_redirect() throw instead of terminating, which is what makes the callback and redirect paths assertable.

The crypto is real: KeyFixture generates actual RSA keys and signs actual tokens, so the negative tests genuinely demonstrate that a forgery is rejected — alg: none, an HS256 token signed with the published public key, a token signed by an unadvertised key, and a tampered payload are each refused.

Both gates run in CI on every push and pull request, offline, via the flake (nix build .#checks.x86_64-linux.phpunit and .phpstan) — the same commands you can run locally. The worker package has its own suite; see worker/README.md.

Browser assets

The WooCommerce fallback script is TypeScript, bundled by rolldown and checked with oxlint / oxfmt — the same toolchain the worker uses.

npm ci
npm run check          # oxfmt + oxlint + type-aware lint + tsc
npm run build          # assets/src/*.ts -> build/woo-login.js + build/woo-login.asset.php
npm test               # vitest + jsdom

build/ is generated and gitignored; nix build .#zip runs the bundler itself (via importNpmLock, so there is no dependency hash to maintain) and ships the output in the plugin zip. The version WordPress caches against is the bundle’s own content hash, read from the generated woo-login.asset.php.

Four further tests boot real WordPress and real WooCommerce on WASM PHP — no Docker, no database:

npm --prefix tests/playground ci
npm run test:assets     # the build manifest matches what enqueueAssets() requires
npm run test:e2e        # logged-out /my-account returns exactly one button
npm run test:browser    # headless Chrome: exactly one button after the script has run
npm run test:exclusive  # JWT_AUTH_EXCLUSIVE, against real WooCommerce and a real HTML parser

test:browser earns its keep. The duplicate-button bug lived only in the post-script DOM — PHP emitted one button, the script added another, and every server-side assertion still counted one.

test:exclusive earns its keep for the same reason in reverse: three of its claims are about somebody else’s code and cannot be made from PHP. That <template> renders the login fields inert is a question for an HTML5 parser. That remove_action() still names WooCommerce’s own hooks correctly is a question for real WooCommerce — a wrong hook, method or priority returns false and says nothing, so the suite pairs each removal against a handler that must still be found the same way. And that get_password_reset_key() refuses is a claim about core.

See tests/playground/README.md, which also documents how to reintroduce the duplicate-button bug and watch the checks fail.


Releasing

Releases are fully automated from Conventional Commits via Release Please. There is no manual version bump — just write conventional commit messages:

On every push to main, the Release workflow opens (or updates) a release PR that accumulates the pending changes and previews the next version + changelog. Merging that PR:

  1. bumps the version in composer.json (and the jwt-auth.php header) and updates CHANGELOG.md;
  2. creates the git tag and a GitHub Release with notes generated from the commits;
  3. builds the plugin on a Nix runner (nix build .#zip) and attaches jwt-auth.zip (with vendor/ bundled) as the release asset.

Client sites then pick up the new version automatically via plugin-update-checker.

The version in the jwt-auth.php plugin header is stamped from composer.json at build time, so composer.json is the single source of truth. (A from-source/dev checkout may show a stale header version until built — the published zip is always correct.)

Repo setting: Settings → Actions → General → Workflow permissions must allow “Read and write permissions” and “Allow GitHub Actions to create and approve pull requests” so Release Please can open the release PR.

License

This plugin is licensed under the MIT license.