This project is not covered by Drupal’s security advisory policy.

A decoupled frontend needs three things Drupal has no answer for: log in, refresh an expiring token, and log out for real. This module adds all three.

Up and running in a few minutes. Beyond the JWT signing key that drupal/jwt already needs, there is nothing in this module to configure: the defaults are the recommended values.

Quick start

1. Install

composer require drupal/jwt_token_refresh
drush en jwt_token_refresh

Composer pulls in drupal/jwt with its Authentication Issuer and Authentication Consumer submodules, and the key module. Nothing else to add.

2. Create a signing key

This is the one real step, and the one that makes every endpoint fail if you skip it. There is no signing-key setting in this module: it signs with the key drupal/jwt already owns.

Go to Administration > Configuration > System > Keys > Add key (/admin/config/system/keys/add). The form grows three sections as you fill it in:

  • Key name -- anything, for instance JWT signing key.
  • Type settings > Key type: JWT HMAC Key. Choosing it reveals JWT Algorithm -- pick HS512 (or HS256).
  • Provider settings > Key provider: Configuration, the default. Leave its Base64-encoded box unchecked.
  • Value > Key value: paste a secret of your own, for instance openssl rand -hex 32 -- 64 characters, the 512 bits HS512 needs. Leave the second Base64-encoded box unchecked as well.

There is no generate button for a JWT key. The input widget is not a choice: the key module derives it from the key type, and no installed type asks for its generate plugin, so a JWT HMAC key gets a plain text field. Bring your own secret.

The two Base64-encoded boxes mean different things. The provider's encodes the secret at rest and decodes it on read; the value's declares that what you paste is already base64 and decodes it before storing. Both unchecked means you paste raw.

Whatever you paste, the form rejects it below 512 bits for HS512, or 256 bits for HS256.

Then select that key at Administration > Configuration > System > JWT Authentication (/admin/config/system/jwt) and press Save configuration. That page pre-selects your only key, so it looks done before it is: nothing is stored until you submit it.

To confirm, open the status report (/admin/reports/status): the line JWT signing key should read Configured. Until it does it reads Not configured as an error, and /auth/token answers 500.

3. Log in

curl -X POST https://example.com/auth/token \
  -H 'Content-Type: application/json' \
  -d '{"name":"alice","pass":"s3cr3t"}'
{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9...",
  "refresh_token": "9f2c7a...",
  "refresh_expires_in": 604800,
  "token_type": "Bearer"
}

The access token lasts an hour by default, the refresh token a week. To carry on from a shell without copying tokens by hand:

S=https://example.com
R=$(curl -s -X POST $S/auth/token -H 'Content-Type: application/json' \
  -d '{"name":"alice","pass":"s3cr3t"}')
ACCESS=$(echo "$R"  | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')
REFRESH=$(echo "$R" | python3 -c 'import json,sys; print(json.load(sys.stdin)["refresh_token"])')

4. Use it

curl -H "Authorization: Bearer $ACCESS_TOKEN" \
  'https://example.com/user/login_status?_format=json'

1

1 means Drupal authenticated the request. Without the header you get 0. From here the same header works on JSON:API, REST, or any route of your own.

5. Refresh, before or after the access token expires

curl -X POST https://example.com/auth/token/refresh \
  -H 'Content-Type: application/json' \
  -d '{"refresh_token":"9f2c7a..."}'

You get a brand-new pair, and the refresh token you just sent is now dead. Store the new one. Sending a spent refresh token again is treated as a theft indicator and revokes the whole chain it came from, so a frontend must never retry a refresh with a token it has already used.

6. Log out

curl -X POST https://example.com/auth/token/revoke \
  -H "Authorization: Bearer $ACCESS_TOKEN"

204 No Content

Read this before you test it. By default that kills the refresh token, so the session can no longer be renewed, but the access token the client already holds stays valid until it expires: Drupal validates a JWT from its signature alone, with no database lookup. If you need a logout that takes effect on the very next request, tick Enable server-side access token revocation at /admin/config/system/jwt-token-refresh. A revoked token then answers 401 session_revoked immediately, at the cost of one indexed lookup per authenticated request.

One thing that will bite you while testing. Five failed logins for the same account return 429 too_many_attempts and lock it for six hours -- the correct password included. That is brute-force protection doing its job on a real site, and a nuisance on a laptop. Clear it with drush sql:query "DELETE FROM flood WHERE event LIKE 'jwt_token_refresh%'".

When something does not work

Every symptom below was met on a real install, in this order.

What you see What it is
404 on /auth/token The module is not enabled. drush en jwt_token_refresh
500 internal_error on login No usable signing key. Check the status report, and remember that /admin/config/system/jwt has to be saved. The reason is named in the jwt_token_refresh log channel.
429 too_many_attempts, even with the right password Flood control: five failed logins lock that account for six hours. Clear it with drush sql:query "DELETE FROM flood WHERE event LIKE 'jwt_token_refresh%'"
401 invalid_token refreshing a token you believe is valid It was already rotated -- or a replay revoked its whole family. Log in again.
400 validation_failed The request body is not JSON, or name/pass are missing.
The access token still works after logout Expected. Immediate revocation is the opt-in setting described in step 6.
Drupal's own HTML 403 instead of a JSON error Your Authorization header carries a Bearer token that is not JWT-shaped, so this module deliberately leaves it to whichever provider owns it.
Every authenticated request is anonymous, on Apache The Authorization header is not reaching PHP. Apache with CGI/FastCGI drops it unless CGIPassAuth On is set -- a server-level problem, not a module one.

Why this module

Here is the situation it fixes. Your React or mobile app authenticates against Drupal and receives a JWT. An hour later the token expires, and the user is dropped mid-session, because there is no way to renew it without asking for the password again. Worse, when they tap "log out", nothing really happens: the token they were holding stays valid until it expires on its own.

Which module you actually want:

  • You only need to issue and validate a JWT: drupal/jwt alone is enough.
  • You need login, silent refresh, and optionally a logout that takes effect immediately: this module.
  • You need real OAuth2, with several client applications, third-party authorization and scopes: simple_oauth.

This module sits on top of drupal/jwt and stays small on purpose. No client registry, no authorization flows, no scopes.

What you get

  • Login and silent refresh. POST /auth/token returns an access token and a refresh token; POST /auth/token/refresh trades the refresh token for a fresh pair. The session survives without ever asking for the password again.
  • A logout that actually logs out. Optional, off by default. Switch it on and a revoked token stops working on the very next request, instead of living until it expires. This is the one thing plain drupal/jwt cannot do. Leave it off and the module stores nothing and costs nothing per request.
  • One device or all of them. Sign out the phone and leave the desktop session alive, or end every session at once.
  • A stolen token does not stay useful. Tokens are rotated on every refresh, and a replayed one takes down the whole chain it came from. See the security design below.
  • Account changes are respected. A new password, a blocked account, a cancellation or a deletion invalidates that user's tokens, with no work on your side.
  • Error codes your frontend can rely on. Every failure returns a stable, machine-readable code (invalid_credentials, token_expired, session_revoked, too_many_attempts) so the app branches on a contract instead of parsing messages.
  • Room for your own payload. Two events let another module add fields to the login and refresh responses without patching this one. And if you already use core's /user/login, one setting adds a refresh_token to its JSON response.

Security design

Every decision below is documented and justified in SECURITY.md, in the repository. If you are reviewing this module before putting it in front of your users, that file is written for you.

  • Nothing is stored in the clear. Refresh tokens and jti values are generated with a CSPRNG (random_bytes) and persisted as SHA-256 hashes only. The raw value reaches the client once, at creation, and is never written down. An unsalted hash is safe here only because the input entropy is high, so the module enforces a 32-byte floor in code, not merely in the settings form.
  • Rotation is atomic, and the write is the lock. Claiming a refresh token is a single conditional UPDATE (revoked 0 to 1). Of two concurrent rotations of the same token, exactly one can succeed. There is no SELECT-then-UPDATE window to race.
  • Reuse detection, as OWASP recommends it. Tokens descending from one login share a family id. Replaying an already-rotated token is treated as a theft indicator and revokes the entire family, including the currently active token. The HTTP response is identical to any other refresh failure, so an attacker learns nothing from it.
  • Immediate revocation without the OAuth2 machinery. The opt-in mode embeds a standard RFC 7519 jti claim and checks it against a database allowlist: one indexed lookup per authenticated request, memoized. Revoking, rotating or logging out also kills the access tokens that the refresh token issued.
  • Login does not leak which accounts exist. Unknown user, wrong password and blocked account all return the same 401 invalid_credentials, in constant time.
  • Brute force is bounded. Flood control applies to login and refresh, per IP and per account, with configurable limits. Rotation also re-checks that the account is still active, so a user blocked after login cannot refresh their way back in.
  • No logout-CSRF. /auth/token/revoke authenticates by Bearer token only; the session cookie deliberately cannot authenticate it.
  • Nothing sensitive in the logs. The dedicated log channel records security events, such as detected token reuse, without raw tokens or raw jti values.
  • Transport. All of the above assumes TLS. These are bearer credentials: whoever holds one is the user. Token responses carry Cache-Control: no-store, but the module cannot protect a token in flight over plain HTTP.

Configuration reference

Everything is at Administration > Configuration > System > JWT Token Refresh (/admin/config/system/jwt-token-refresh), and every field ships with a working default: access and refresh token lifetimes, how many refresh tokens a user may hold, refresh token and jti entropy, brute-force limits per IP and per account, purging expired tokens on cron, the immediate-logout toggle, and an option to add a refresh token to core's /user/login JSON response.

One default worth changing. The jwt_auth_issuer submodule ships with Include a JWT token in the user login response enabled, which adds an access token to core's /user/login?_format=json. That submodule has no concept of refresh, so the token it hands out cannot be renewed: it behaves like a working login until it expires. If your frontend uses this module's POST /auth/token -- the endpoint meant for it -- uncheck that box. If you would rather keep using core's login route, turn on this module's Inject a refresh token into /user/login instead, and the response carries a refresh token too. Note that core's login route also opens a Drupal session cookie, where /auth/token is stateless by design.

The README documents every endpoint with request and response examples, the complete error code catalogue, and the two events.

Additional Requirements

Drupal ^10.3 || ^11, PHP 8.1 or later, and drupal/jwt ^2.0 with its JWT Authentication Issuer and JWT Authentication Consumer submodules. Both are declared as dependencies, so Composer pulls them in. You will need a configured JWT signing key, via the key module that jwt already requires -- see step 2 of the quick start.

Tested on Drupal 10.6 with PHP 8.2 and Drupal 11.4 with PHP 8.4: 56 automated tests, PHPCS clean against Drupal and DrupalPractice, PHPStan at level 6.

None. Beyond drupal/jwt, the module deliberately has no dependencies.

Project information

Releases