JWT Token Refresh
This module allows decoupled applications to log in users, silently refresh expiring tokens without re-authentication, and log users out effectively. It enhances security by rotating tokens on refresh, invalidating them on account changes, and offering an optional immediate logout feature.
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.
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.
Post-Installation
Configure a signing key in the jwt module, and you are done: the defaults work as they are.
If you want to adjust them, everything is at Administration > Configuration > System > JWT Token Refresh (/admin/config/system/jwt-token-refresh): how long tokens live, how many a user may hold, token entropy, brute-force limits, cleanup on cron, and the immediate-logout toggle.
The README documents every endpoint with request and response examples, the complete error code catalogue, and the 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.
Tested on Drupal 10.6 with PHP 8.2 and Drupal 11.4 with PHP 8.4: 50 automated tests, PHPCS clean against Drupal and DrupalPractice, PHPStan at level 6.
Recommended modules/libraries
None. Beyond drupal/jwt, the module deliberately has no dependencies.