Makrosites

Suas ideias em realidade digital!

Refresh Token em PHP: JWT avançado (access token curto + renovação segura)

Refresh Token em PHP: JWT avançado (access token curto + renovação segura)

Por que usar Refresh Token (JWT avançado)?

Em APIs com JWT, o ideal é que o access token tenha vida curta (ex: 10–15 minutos). Assim, se ele vazar, o estrago é limitado. Mas aí surge o problema: o usuário teria que logar toda hora.

A solução é usar um refresh token (vida longa, ex: 7 a 30 dias) para gerar novos access tokens sem pedir senha novamente.

Access token vs Refresh token

Fluxo recomendado

  1. Usuário faz login
  2. API retorna access token (curto) + grava refresh token (longo)
  3. Quando o access expira, o front chama /api/auth/refresh
  4. API valida refresh, emite novo access e (ideal) rotaciona o refresh

Rotas que você vai implementar


1) Tabela para armazenar refresh tokens (recomendado)

Guardar refresh token no banco permite revogar, rotacionar e controlar sessões. Exemplo de tabela simples:

CREATE TABLE refresh_tokens (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  user_id BIGINT NOT NULL,
  token_hash VARCHAR(255) NOT NULL,
  expires_at DATETIME NOT NULL,
  revoked_at DATETIME NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  last_used_at DATETIME NULL,
  user_agent VARCHAR(255) NULL,
  ip VARCHAR(64) NULL,
  INDEX (user_id),
  INDEX (token_hash)
);

✅ Armazene hash do refresh token, não o token em texto puro.


2) Gerando tokens: access curto + refresh longo

Você pode manter o JWT manual (como no post anterior) ou usar biblioteca. Aqui vou usar o mesmo padrão: JWT no access + refresh aleatório (mais seguro).

Gerar refresh token aleatório

<?php
function generateRefreshToken(): string {
  return bin2hex(random_bytes(48)); // 96 chars
}

Salvar refresh no banco (hash)

<?php
function saveRefreshToken(PDO $pdo, int $userId, string $refreshToken, int $days = 15): array {
  $hash = password_hash($refreshToken, PASSWORD_BCRYPT);
  $expiresAt = (new DateTimeImmutable())->modify("+{$days} days");

  $stmt = $pdo->prepare("INSERT INTO refresh_tokens (user_id, token_hash, expires_at, user_agent, ip)
                         VALUES (:uid, :hash, :exp, :ua, :ip)");
  $stmt->execute([
    ':uid' => $userId,
    ':hash' => $hash,
    ':exp' => $expiresAt->format('Y-m-d H:i:s'),
    ':ua' => ($_SERVER['HTTP_USER_AGENT'] ?? null),
    ':ip' => ($_SERVER['REMOTE_ADDR'] ?? null),
  ]);

  return ['id' => (int)$pdo->lastInsertId(), 'expires_at' => $expiresAt];
}

🔐 Dica: se você quiser suportar múltiplos dispositivos, não sobrescreva tokens antigos — crie um por sessão.


3) Login: retorna access e define refresh em cookie httpOnly

<?php
// POST /api/auth/login

// 1) valida credenciais...
$userId = 1; // exemplo

$access = generateJWT([
  'id' => $userId,
  'role' => 'user'
], 'SEGREDO_SUPER_SECRETO', 900); // 15 min

$refresh = generateRefreshToken();

// salva refresh no banco
$pdo = getPDO(); // sua conexão
$stored = saveRefreshToken($pdo, $userId, $refresh, 15);

// grava cookie httpOnly
setcookie('refresh_token', $refresh, [
  'expires' => $stored['expires_at']->getTimestamp(),
  'path' => '/api/auth',
  'secure' => true,
  'httponly' => true,
  'samesite' => 'Lax'
]);

echo json_encode(['access_token' => $access, 'expires_in' => 900]);

✅ Access token vai no front. ✅ Refresh token fica em cookie httpOnly (front não lê via JS).


4) Refresh: valida cookie, rotaciona e devolve novo access

A prática mais segura é rotacionar o refresh token: cada uso gera um novo e revoga o anterior. Isso reduz risco de replay se vazar.

<?php
// POST /api/auth/refresh

$refresh = $_COOKIE['refresh_token'] ?? '';
if (!$refresh) {
  http_response_code(401);
  echo json_encode(['error' => 'Refresh token ausente']);
  exit;
}

$pdo = getPDO();

// 1) buscar tokens válidos não revogados e não expirados
$stmt = $pdo->prepare("SELECT id, user_id, token_hash, expires_at, revoked_at
                       FROM refresh_tokens
                       WHERE revoked_at IS NULL AND expires_at > NOW()
                       ORDER BY id DESC LIMIT 50");
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

// 2) comparar com password_verify (hash)
$found = null;
foreach ($rows as $r) {
  if (password_verify($refresh, $r['token_hash'])) { $found = $r; break; }
}

if (!$found) {
  http_response_code(401);
  echo json_encode(['error' => 'Refresh inválido ou expirado']);
  exit;
}

// 3) rotacionar: revoga o token atual
$pdo->prepare("UPDATE refresh_tokens SET revoked_at = NOW(), last_used_at = NOW() WHERE id = :id")
    ->execute([':id' => $found['id']]);

// 4) emite novo refresh + salva
$newRefresh = generateRefreshToken();
$stored = saveRefreshToken($pdo, (int)$found['user_id'], $newRefresh, 15);

// 5) emite novo access
$newAccess = generateJWT([
  'id' => (int)$found['user_id'],
  'role' => 'user'
], 'SEGREDO_SUPER_SECRETO', 900);

// 6) atualiza cookie
setcookie('refresh_token', $newRefresh, [
  'expires' => $stored['expires_at']->getTimestamp(),
  'path' => '/api/auth',
  'secure' => true,
  'httponly' => true,
  'samesite' => 'Lax'
]);

echo json_encode(['access_token' => $newAccess, 'expires_in' => 900]);

💡 Em produção, em vez de varrer “50 tokens”, o ideal é incluir um identificador (ex: jti) ou token_id. Aqui mantive simples e didático.


5) Logout: revoga refresh e apaga cookie

<?php
// POST /api/auth/logout

$refresh = $_COOKIE['refresh_token'] ?? '';
if ($refresh) {
  $pdo = getPDO();

  // tenta encontrar e revogar o token
  $stmt = $pdo->prepare("SELECT id, token_hash FROM refresh_tokens
                         WHERE revoked_at IS NULL AND expires_at > NOW()
                         ORDER BY id DESC LIMIT 100");
  $stmt->execute();
  $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

  foreach ($rows as $r) {
    if (password_verify($refresh, $r['token_hash'])) {
      $pdo->prepare("UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = :id")
          ->execute([':id' => $r['id']]);
      break;
    }
  }
}

// remove cookie
setcookie('refresh_token', '', [
  'expires' => time() - 3600,
  'path' => '/api/auth',
  'secure' => true,
  'httponly' => true,
  'samesite' => 'Lax'
]);

echo json_encode(['ok' => true]);

Boas práticas essenciais (produção)

Erros comuns