Skip to content

Como Fazer: Rotação de Refresh Token JWT

Este guia cobre a implementação de tokens de acesso de curta duração combinados com tokens de refresh de longa duração. A propriedade chave é a rotação: cada uso de um token de refresh o revoga imediatamente e emite um novo. Um token de refresh reutilizado (já revogado) aciona a revogação de todos os tokens desse usuário.


Por que dois tokens?

TokenTTLArmazenamentoFinalidade
Token de acesso5 minMemória do clienteAutentica requisições de API (stateless, sem consulta ao banco)
Token de refresh7 diasBanco (hash)Emite novos tokens de acesso; gerenciado via rotação

Um token de acesso de curta duração limita o dano se vazar — expira em minutos. O token de refresh estende a sessão sem exigir novo login, mas é revogável porque vive no banco de dados.


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,  -- hash SHA-256; nunca o valor bruto
    expires_at TEXT    NOT NULL,
    revoked    INTEGER NOT NULL DEFAULT 0,
    created_at TEXT    NOT NULL
);

token_hash — sempre armazene o hash, nunca o token bruto. Se o banco vazar, tokens hasheados não podem ser usados diretamente.


Emitindo tokens

Token de acesso: adicionar jti para unicidade

Sem jti, dois tokens emitidos no mesmo segundo para o mesmo usuário são idênticos — seus payloads são iguais byte a byte. jti (JWT ID) garante que cada token seja único e é a base para listas de bloqueio futuras de tokens de acesso:

php
$accessToken = $this->issuer->issue([
    'jti'   => bin2hex(random_bytes(8)),  // único por emissão
    'sub'   => $user->id,
    'email' => $user->email,
    'iat'   => time(),
    'exp'   => time() + 300,  // 5 minutos
]);

Token de refresh: armazenar o hash, retornar o valor bruto

php
public function issue(int $userId): string
{
    $raw       = bin2hex(random_bytes(32));  // token aleatório de 256 bits
    $hash      = hash('sha256', $raw);       // armazenar apenas isso
    $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;  // o cliente recebe isso; o banco nunca armazena
}

Para buscar um token de um valor fornecido pelo cliente:

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

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

Rotação de token

Cada requisição de refresh deve revogar o token antigo antes de emitir um novo:

php
private function refresh(ServerRequestInterface $request): ResponseInterface
{
    // ... analisar corpo, encontrar token armazenado ...

    if ($stored === null || !$stored->isValid()) {
        // Reutilização de um token revogado é um potencial ataque de replay —
        // revogar todos os tokens do usuário para forçar reautenticação.
        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);

    // Rotação: revogar token antigo primeiro, depois emitir novo par
    $this->refreshTokens->revoke($stored->id);

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

Detecção de reutilização: se um token de refresh revogado chegar ao endpoint /auth/refresh, significa que o usuário está reproduzindo um token antigo (incomum) ou um atacante o roubou. revokeAllForUser() força todas as sessões a reautenticar, limitando o raio de explosão.


Logout: sempre retornar 204

Nunca retorne diferentes códigos de status dependendo de o token de refresh ser válido. Fazer isso permite a um atacante sondar se um token ainda está ativo:

php
private function logout(ServerRequestInterface $request): ResponseInterface
{
    // ... analisar corpo ...

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

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

    // Sempre 204 — nunca vazar se o token era válido ou não
    return $this->json->createEmpty(204);
}

Isso também significa que logout duplo (chamar logout duas vezes com o mesmo token) retorna 204 em ambas as vezes — o cliente pode sempre chamar logout com segurança sem se preocupar com o estado do token.


Verificação de validade na entidade RefreshToken

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

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

A comparação de string funciona para datas ISO-8601 ordenadas lexicograficamente. Se você armazenar timestamps como inteiros Unix, compare com time() em vez disso.


BearerTokenMiddleware: excluir caminhos de refresh/logout

Os endpoints de refresh e logout recebem um token de refresh no corpo, não um token de acesso Bearer no header Authorization. Exclua-os do BearerTokenMiddleware:

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

O endpoint /auth/me (e qualquer outro caminho protegido) permanece protegido pelo middleware.


Formato da resposta

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

expires_in (segundos) permite que o cliente agende um refresh proativo antes que o token de acesso expire, evitando uma requisição falha seguida de um refresh.


Checklist de revisão de código

  1. A coluna token_hash armazena hash('sha256', $raw) — nunca o valor bruto
  2. revoke() é chamado antes de issueTokenPair() no handler de refresh
  3. Reutilização de token revogado aciona revokeAllForUser() (não apenas um 401)
  4. Logout sempre retorna 204 — sem 401/404 condicional
  5. TTL do token de acesso é curto (≤ 15 minutos)
  6. Claim jti está presente nos tokens de acesso
  7. Os testes cobrem rotação cruzada de tokens (token antigo inválido após refresh) e detecção de reutilização

Testando rotação e detecção de reutilização

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

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

    // O token antigo deve ser rejeitado
    $res = $this->post('/auth/refresh', ['refresh_token' => $tokens['refresh_token']]);
    $this->assertSame(401, $res->getStatusCode());
}

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

    // Rotacionar uma vez — o token antigo agora está revogado
    $newTokens = $this->json($this->post('/auth/refresh', ['refresh_token' => $tokens['refresh_token']]));

    // Atacante reproduz o token antigo (revogado) — aciona revokeAllForUser()
    $this->post('/auth/refresh', ['refresh_token' => $tokens['refresh_token']]);

    // O token de refresh recém-emitido agora também está revogado
    $res = $this->post('/auth/refresh', ['refresh_token' => (string) $newTokens['refresh_token']]);
    $this->assertSame(401, $res->getStatusCode());
}

Veja também

  • docs/howto/jwt-authentication.md — emissão JWT, BearerTokenMiddleware, nene2.auth.claims
  • docs/howto/password-hashing.md — Argon2id, padrão de hash dummy para prevenção de enumeração de usuário

Publicado sob a licença MIT.