Skip to content

How-To: JWT Refresh Token Rotation

This guide covers implementing short-lived access tokens combined with long-lived refresh tokens. The key property is rotation: every use of a refresh token immediately revokes it and issues a new one. A reused (already-revoked) refresh token triggers revocation of all tokens for that user.


Why two tokens?

TokenTTLStoragePurpose
Access token5 minClient memoryAuthenticates API requests (stateless, no DB lookup)
Refresh token7 daysDB (hashed)Issues new access tokens; managed via rotation

A short-lived access token limits the damage if it leaks — it expires in minutes. The refresh token extends the session without requiring re-login, but it is revocable because it lives in the database.


Schema

sql
CREATE TABLE refresh_tokens (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL REFERENCES users(id),
    token_hash TEXT    NOT NULL UNIQUE,  -- SHA-256 hash; never the raw value
    expires_at TEXT    NOT NULL,
    revoked    INTEGER NOT NULL DEFAULT 0,
    created_at TEXT    NOT NULL
);

token_hash — always store the hash, never the raw token. If the DB leaks, hashed tokens cannot be used directly.


Issuing tokens

Access token: add jti for uniqueness

Without jti, two tokens issued in the same second for the same user are identical — their payloads are byte-for-byte equal. jti (JWT ID) guarantees each token is unique and is the foundation for future access-token blocklists:

php
$accessToken = $this->issuer->issue([
    'jti'   => bin2hex(random_bytes(8)),  // unique per issuance
    'sub'   => $user->id,
    'email' => $user->email,
    'iat'   => time(),
    'exp'   => time() + 300,  // 5 minutes
]);

Refresh token: store the hash, return the raw value

php
public function issue(int $userId): string
{
    $raw       = bin2hex(random_bytes(32));  // 256-bit random token
    $hash      = hash('sha256', $raw);       // store only this
    $expiresAt = (new \DateTimeImmutable())->modify('+7 days')->format('Y-m-d\TH:i:s\Z');
    $createdAt = (new \DateTimeImmutable())->format('Y-m-d\TH:i:s\Z');

    $this->executor->insert(
        'INSERT INTO refresh_tokens (user_id, token_hash, expires_at, revoked, created_at)
         VALUES (?, ?, ?, 0, ?)',
        [$userId, $hash, $expiresAt, $createdAt],
    );

    return $raw;  // the client receives this; the DB never stores it
}

To look up a token from a client-supplied value:

php
public function findByRaw(string $raw): ?RefreshToken
{
    $hash = hash('sha256', $raw);

    $row = $this->executor->fetchOne(
        'SELECT ... FROM refresh_tokens WHERE token_hash = ?',
        [$hash],
    );
    // ...
}

Token rotation

Every refresh request must revoke the old token before issuing a new one:

php
private function refresh(ServerRequestInterface $request): ResponseInterface
{
    // ... parse body, find stored token ...

    if ($stored === null || !$stored->isValid()) {
        // Reuse of a revoked token is a potential replay attack —
        // revoke all tokens for the user to force re-authentication.
        if ($stored !== null && $stored->revoked) {
            $this->refreshTokens->revokeAllForUser($stored->userId);
        }

        return $this->problems->create(
            $request,
            'invalid-refresh-token',
            'Invalid or Expired Refresh Token',
            401,
            'The refresh token is invalid, expired, or has already been used.',
        );
    }

    $user = $this->users->findById($stored->userId);

    // Rotation: revoke old token first, then issue new pair
    $this->refreshTokens->revoke($stored->id);

    return $this->json->create($this->issueTokenPair($user));
}

Reuse detection: if a revoked refresh token arrives at the /auth/refresh endpoint, it means either the user is replaying an old token (unusual) or an attacker stole it. revokeAllForUser() forces every session to re-authenticate, limiting the blast radius.


Logout: always return 204

Never return different status codes depending on whether the refresh token was valid. Doing so lets an attacker probe whether a token is still active:

php
private function logout(ServerRequestInterface $request): ResponseInterface
{
    // ... parse body ...

    $stored = $this->refreshTokens->findByRaw($body['refresh_token']);

    if ($stored !== null && !$stored->revoked) {
        $this->refreshTokens->revoke($stored->id);
    }

    // Always 204 — never leak whether the token was valid or not
    return $this->json->createEmpty(204);
}

This also means double-logout (calling logout twice with the same token) returns 204 both times — the client can always call logout safely without worrying about the token's state.


Validity check on the RefreshToken entity

php
public function isValid(): bool
{
    if ($this->revoked) {
        return false;
    }

    return $this->expiresAt > (new \DateTimeImmutable())->format('Y-m-d\TH:i:s\Z');
}

String comparison works for ISO-8601 dates sorted lexicographically. If you store timestamps as Unix integers, compare with time() instead.


BearerTokenMiddleware: exclude refresh/logout paths

The refresh and logout endpoints receive a refresh token in the body, not a Bearer access token in the Authorization header. Exclude them from BearerTokenMiddleware:

php
$authMiddleware = new BearerTokenMiddleware(
    problemDetails: $problems,
    verifier: $verifier,
    excludedPaths: ['/auth/login', '/auth/refresh', '/auth/logout'],
);

The /auth/me endpoint (and any other protected path) stays protected by the middleware.


Response shape

json
{
  "access_token":  "eyJhbGci...",
  "token_type":    "Bearer",
  "expires_in":    300,
  "refresh_token": "a3f92c..."
}

expires_in (seconds) lets the client schedule a proactive refresh before the access token expires, avoiding a failed request followed by a refresh.


Code-review checklist

  1. token_hash column stores hash('sha256', $raw) — never the raw value
  2. revoke() is called before issueTokenPair() in the refresh handler
  3. Revoked-token reuse triggers revokeAllForUser() (not just a 401)
  4. Logout always returns 204 — no conditional 401/404
  5. Access token TTL is short (≤ 15 minutes)
  6. jti claim is present in access tokens
  7. Tests cover cross-token-rotation (old token invalid after refresh) and reuse detection

Testing rotation and reuse detection

php
public function testRefreshTokenRotation_OldTokenIsInvalidAfterRefresh(): void
{
    $tokens = $this->login();

    $this->post('/auth/refresh', ['refresh_token' => $tokens['refresh_token']]);

    // Old token must be rejected
    $res = $this->post('/auth/refresh', ['refresh_token' => $tokens['refresh_token']]);
    $this->assertSame(401, $res->getStatusCode());
}

public function testRefreshTokenReuseRevokesAllUserTokens(): void
{
    $tokens = $this->login();

    // Rotate once — old token is now revoked
    $newTokens = $this->json($this->post('/auth/refresh', ['refresh_token' => $tokens['refresh_token']]));

    // Attacker replays the old (revoked) refresh token — triggers revokeAllForUser()
    $this->post('/auth/refresh', ['refresh_token' => $tokens['refresh_token']]);

    // The newly issued refresh token is now also revoked
    $res = $this->post('/auth/refresh', ['refresh_token' => (string) $newTokens['refresh_token']]);
    $this->assertSame(401, $res->getStatusCode());
}

See also

  • docs/howto/jwt-authentication.md — JWT issuance, BearerTokenMiddleware, nene2.auth.claims
  • docs/howto/password-hashing.md — Argon2id, dummy hash pattern for user enumeration prevention
  • docs/field-trials/2026-05-field-trial-113.md — Refresh token rotation field trial

Released under the MIT License.