Refresh tokens en PHP explicados fácil: cómo mantener sesiones seguras sin pedir login a cada rato


Introducción


Cuando empiezas a trabajar con JWT, al principio todo parece simple:

  • el usuario hace login
  • el servidor genera un token
  • el cliente lo usa para acceder a rutas protegidas

Y listo.


Pero luego aparece un problema muy real:

👉 ¿qué pasa cuando el token expira?

Si el token dura poco, la seguridad mejora, pero el usuario tendría que iniciar sesión continuamente.

Si el token dura demasiado, la experiencia mejora, pero aumentas el riesgo si ese token se filtra o es robado.

Aquí es donde entran los refresh tokens.


Los refresh tokens sirven para renovar el acceso sin obligar al usuario a volver a hacer login constantemente. Son una pieza muy importante en autenticación moderna porque ayudan a equilibrar:

  • seguridad
  • experiencia de usuario
  • control de sesiones
  • revocación de acceso

En este post vamos a ver qué son, cómo funcionan, por qué no son lo mismo que un JWT de acceso y cómo implementarlos en PHP y MySQL de forma clara, práctica y razonable.

La idea no es solo copiar código, sino que entiendas la lógica correcta detrás del sistema.



Qué es un refresh token


Un refresh token es un token especial que se utiliza para conseguir un nuevo access token cuando el token principal ha expirado.

La idea general del flujo es esta:

  1. el usuario hace login
  2. el servidor devuelve dos cosas:
  • un access token
  • un refresh token
  1. el access token se usa para acceder a rutas protegidas
  2. cuando el access token expira, el cliente usa el refresh token
  3. el servidor valida el refresh token y, si sigue siendo válido, emite un nuevo access token

De esta forma, no necesitas pedir usuario y contraseña cada vez que vence el token principal.



Diferencia entre access token y refresh token


Esta diferencia es clave.

Access token

Es el JWT que usas para acceder a la API.


Suele:

  • durar poco
  • viajar en peticiones protegidas
  • contener identidad del usuario
  • expirar pronto


Ejemplo típico:

  • 15 minutos
  • 30 minutos
  • 1 hora


Refresh token

Es el token que usas solo para renovar el access token.


Suele:

  • durar más
  • usarse menos veces
  • guardarse con más cuidado
  • no enviarse en cada petición normal
  • servir para pedir nuevos access tokens


Ejemplo típico:

  • varios días
  • una semana
  • varias semanas, según el sistema


La lógica correcta suele ser:

  • token de acceso corto
  • token de refresco más largo


Por qué no basta con un JWT largo


Mucha gente al principio piensa:

“mejor hago que el JWT dure 30 días y ya”.

Eso simplifica algunas cosas, sí. Pero también empeora la seguridad.

Si un access token dura demasiado y alguien lo roba, esa persona tiene acceso durante mucho tiempo.


En cambio, con un access token corto:

  • reduces ventana de riesgo
  • limitas el impacto de filtraciones
  • obligas a renovar más a menudo
  • mantienes mejor control sobre la sesión

El refresh token existe precisamente para no sacrificar experiencia de usuario mientras mejoras seguridad.



Cómo funciona el flujo completo con refresh tokens


La lógica completa suele verse así:

1. Login


El usuario manda email y contraseña.


2. El servidor valida credenciales

Si son correctas:

  • genera un access token
  • genera un refresh token
  • guarda el refresh token en base de datos
  • responde ambos tokens


3. El cliente usa el access token

Lo manda en las peticiones protegidas.


4. El access token expira

La API responde algo como:

  • 401 Unauthorized
  • token expirado


5. El cliente llama al endpoint de refresh

Envía el refresh token.


6. El servidor valida el refresh token

Si es correcto:

  • genera un nuevo access token
  • opcionalmente genera también un nuevo refresh token
  • actualiza lo necesario


7. El cliente sigue trabajando sin login manual

Y la sesión continúa de forma más fluida.



Ventajas de usar refresh tokens


Implementarlos bien tiene varias ventajas:

  • mejor experiencia de usuario
  • access tokens más cortos y seguros
  • posibilidad de controlar sesiones
  • posibilidad de revocar refresh tokens
  • mejor equilibrio entre comodidad y seguridad

En APIs modernas, especialmente para apps móviles o frontend SPA, esto es muy habitual.



Riesgos si los implementas mal


También es importante entender esto:

un refresh token mal planteado puede convertirse en un problema serio.


Errores típicos:

  • darles demasiada duración sin control
  • no guardarlos en base de datos
  • no permitir revocarlos
  • reutilizarlos sin estrategia clara
  • guardarlos de forma insegura
  • no invalidarlos al cerrar sesión

Por eso conviene montar el sistema con cierta lógica, no como simple “token extra”.



Cómo plantearlo bien en PHP y MySQL


Una estrategia bastante razonable para muchos proyectos es esta:

  • usar JWT como access token
  • usar un refresh token aleatorio
  • guardar refresh token en base de datos
  • asociarlo a un usuario
  • darle fecha de expiración
  • permitir invalidarlo
  • usar endpoint específico para renovar tokens

Esto suele ser mejor que usar un segundo JWT también como refresh token sin demasiado control, aunque ambas estrategias existen.

Para muchos sistemas hechos con PHP y MySQL, el refresh token aleatorio guardado en base de datos da bastante control.



Estructura de base de datos recomendada


Te conviene una tabla específica para refresh tokens.


Ejemplo:

CREATE TABLE refresh_tokens (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  token VARCHAR(255) NOT NULL UNIQUE,
  expires_at DATETIME NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  revoked TINYINT(1) DEFAULT 0,
  FOREIGN KEY (user_id) REFERENCES usuarios(id) ON DELETE CASCADE
);


Qué guarda esta tabla

  • a qué usuario pertenece el token
  • cuál es el token
  • cuándo expira
  • si fue revocado o no
  • cuándo se creó

Esto te da mucho más control que simplemente confiar en algo “estateless” para todo.



Por qué conviene guardar refresh tokens en base de datos


Porque eso te permite:

  • invalidarlos manualmente
  • revocarlos en logout
  • controlar expiración
  • detectar reutilización
  • eliminar tokens viejos
  • gestionar sesiones por dispositivo si más adelante quieres crecer

En cambio, si no los guardas, pierdes bastante capacidad de control.



Qué duración poner al access token y al refresh token


No hay una única regla universal, pero una estrategia muy razonable puede ser:

Access token

  • 15 minutos
  • 30 minutos
  • 1 hora


Refresh token

  • 7 días
  • 15 días
  • 30 días


Depende del proyecto, el tipo de cliente y el nivel de seguridad que necesites.

Para un sistema moderado:

  • access token de 15 o 30 minutos
  • refresh token de 7 días

suele ser una base sensata.



Implementación paso a paso

Vamos a plantear una implementación clara.



Paso 1: instalar JWT si no lo tienes

composer require firebase/php-jwt


Paso 2: tabla para refresh tokens

Ya la vimos arriba, pero aquí la dejamos lista de nuevo:

CREATE TABLE refresh_tokens (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  token VARCHAR(255) NOT NULL UNIQUE,
  expires_at DATETIME NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  revoked TINYINT(1) DEFAULT 0,
  FOREIGN KEY (user_id) REFERENCES usuarios(id) ON DELETE CASCADE
);


Paso 3: generar access token y refresh token en login

Aquí partimos de que ya validaste email y password correctamente.

Ejemplo:

<?php
use Firebase\JWT\JWT;

$key = "clave_super_segura";

$payload = [
    "user_id" => $user['id'],
    "email"   => $user['email'],
    "exp"     => time() + (15 * 60) // 15 minutos
];

$accessToken = JWT::encode($payload, $key, 'HS256');

$refreshToken = bin2hex(random_bytes(64));
$expiresAt = date('Y-m-d H:i:s', strtotime('+7 days'));

$stmt = $pdo->prepare("
    INSERT INTO refresh_tokens (user_id, token, expires_at)
    VALUES (?, ?, ?)
");
$stmt->execute([
    $user['id'],
    $refreshToken,
    $expiresAt
]);

echo json_encode([
    "access_token" => $accessToken,
    "refresh_token" => $refreshToken
]);


Qué está pasando aquí

  • se crea un JWT corto
  • se genera un refresh token aleatorio largo
  • se guarda en base de datos
  • se devuelven ambos al cliente


Por qué usar random_bytes para el refresh token

Porque necesitas un token difícil de predecir y suficientemente aleatorio.

Usar algo como:

  • ids simples
  • timestamps
  • strings previsibles

sería mala idea.

random_bytes es una forma mucho más segura de generar tokens aleatorios en PHP.



Paso 4: crear endpoint para refrescar token

Ahora necesitas un endpoint específico, por ejemplo:

POST /refresh


El cliente enviará el refresh token y el servidor validará si:

  • existe
  • no está revocado
  • no ha expirado

Si pasa la validación, devuelves un nuevo access token.


Ejemplo:

<?php
use Firebase\JWT\JWT;

$key = "clave_super_segura";

$data = json_decode(file_get_contents("php://input"), true);

if (!isset($data['refresh_token'])) {
    http_response_code(400);
    echo json_encode(["error" => "Refresh token requerido"]);
    exit;
}

$refreshToken = $data['refresh_token'];

$stmt = $pdo->prepare("
    SELECT * FROM refresh_tokens
    WHERE token = ?
      AND revoked = 0
      AND expires_at > NOW()
    LIMIT 1
");
$stmt->execute([$refreshToken]);

$storedToken = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$storedToken) {
    http_response_code(401);
    echo json_encode(["error" => "Refresh token inválido o expirado"]);
    exit;
}

$stmt = $pdo->prepare("
    SELECT id, email
    FROM usuarios
    WHERE id = ?
    LIMIT 1
");
$stmt->execute([$storedToken['user_id']]);

$user = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$user) {
    http_response_code(401);
    echo json_encode(["error" => "Usuario no válido"]);
    exit;
}

$payload = [
    "user_id" => $user['id'],
    "email"   => $user['email'],
    "exp"     => time() + (15 * 60)
];

$newAccessToken = JWT::encode($payload, $key, 'HS256');

echo json_encode([
    "access_token" => $newAccessToken
]);


Qué hace este endpoint

  1. recibe el refresh token
  2. lo busca en base de datos
  3. comprueba que no esté revocado
  4. comprueba que no haya expirado
  5. busca el usuario asociado
  6. genera nuevo access token
  7. lo devuelve

Con eso, el cliente puede seguir usando la API sin volver a loguearse manualmente.



¿Conviene regenerar también el refresh token?

Aquí hay dos enfoques.

Enfoque simple

Mantienes el mismo refresh token hasta que expire.


Ventajas:

  • más simple
  • menos lógica adicional

Enfoque más seguro


Cada vez que usas un refresh token:

  • lo invalidas
  • generas uno nuevo
  • guardas el nuevo
  • devuelves también el nuevo al cliente

Esto se llama a veces refresh token rotation.


Ventajas:

  • más seguridad
  • mejor capacidad para detectar reutilización maliciosa

Para empezar, puedes montar el sistema simple.

Pero si quieres hacerlo más serio, la rotación es una mejora muy buena.


Ejemplo de refresh token rotation


En vez de devolver solo el access token, podrías:

  1. revocar el refresh token usado
  2. generar uno nuevo
  3. insertarlo en base de datos
  4. devolver ambos tokens


Ejemplo simplificado:

<?php
$newRefreshToken = bin2hex(random_bytes(64));
$newExpiresAt = date('Y-m-d H:i:s', strtotime('+7 days'));

$pdo->prepare("
    UPDATE refresh_tokens
    SET revoked = 1
    WHERE id = ?
")->execute([$storedToken['id']]);

$pdo->prepare("
    INSERT INTO refresh_tokens (user_id, token, expires_at)
    VALUES (?, ?, ?)
")->execute([
    $user['id'],
    $newRefreshToken,
    $newExpiresAt
]);

echo json_encode([
    "access_token" => $newAccessToken,
    "refresh_token" => $newRefreshToken
]);

Esto ya es un enfoque mucho más robusto.



Cómo hacer logout correctamente con refresh tokens


Si usas refresh tokens, el logout debería invalidarlos.


Por ejemplo:

  • el cliente manda el refresh token actual
  • el servidor lo marca como revocado


Ejemplo:

<?php
$data = json_decode(file_get_contents("php://input"), true);

if (!isset($data['refresh_token'])) {
    http_response_code(400);
    echo json_encode(["error" => "Refresh token requerido"]);
    exit;
}

$stmt = $pdo->prepare("
    UPDATE refresh_tokens
    SET revoked = 1
    WHERE token = ?
");
$stmt->execute([$data['refresh_token']]);

echo json_encode([
    "message" => "Sesión cerrada correctamente"
]);

Esto ayuda mucho a mantener control real sobre sesiones.



Dónde guardar el refresh token en el cliente


Aquí depende del tipo de aplicación.

En frontend SPA

Mucha gente debate entre:

  • localStorage
  • cookies seguras HttpOnly

Desde el punto de vista de seguridad, muchas veces se considera más prudente usar cookies seguras y HttpOnly para refresh tokens, porque reduces exposición frente a ciertos ataques de JavaScript.


En apps móviles

El almacenamiento depende del entorno, pero conviene usar mecanismos seguros del sistema.


En proyectos simples

Aunque técnicamente podrías usar localStorage, conviene entender que no es siempre la opción más fuerte de seguridad.

No hay que convertir este punto en guerra absoluta, pero sí merece criterio.



Qué errores evitar con refresh tokens


1. Guardar refresh tokens sin expiración

Mala idea. Necesitan fecha de vencimiento.

2. No guardarlos en base de datos

Pierdes capacidad de revocación y control.

3. Darles demasiada duración

Aumenta el riesgo si se filtran.

4. No invalidarlos en logout

Entonces la “sesión” sigue viva.

5. No diferenciar access token y refresh token

Cada uno cumple un rol distinto.

6. Mandar refresh token en todas las peticiones normales

No hace falta. Solo debe usarse para refrescar.

7. No usar HTTPS

Esto es crítico. Sin HTTPS, cualquier sistema de tokens queda mucho más expuesto.



Flujo completo de autenticación con refresh tokens


Login


Devuelves:

  • access token corto
  • refresh token largo

Requests normales


Usan:

  • access token

Access token expira

El cliente llama:

  • /refresh

Refresh correcto

El servidor devuelve:

  • nuevo access token
  • opcionalmente nuevo refresh token

Logout

El servidor:

  • revoca refresh token

Ese es el ciclo principal.



Cómo queda la arquitectura de endpoints


Tu sistema podría tener algo así:

POST /register
POST /login
POST /refresh
POST /logout
GET /profile

Eso ya es una base bastante seria para un backend moderno con PHP y MySQL.



Buenas prácticas recomendadas

  • usa access tokens cortos
  • usa refresh tokens más largos pero controlados
  • guárdalos en base de datos
  • añade expiración
  • revócalos en logout
  • considera rotación si quieres más seguridad
  • usa HTTPS siempre
  • no metas datos sensibles en el payload JWT
  • registra eventos relevantes de autenticación si tu sistema crece


Cuándo conviene usar refresh tokens


Son especialmente útiles cuando:

  • tienes frontend SPA
  • tienes apps móviles
  • usas APIs con sesiones largas
  • quieres buena UX sin sacrificar seguridad
  • necesitas renovar tokens automáticamente


Cuándo quizá no haga falta tanta complejidad


Si tu proyecto es:

  • muy pequeño
  • administrativo interno
  • con sesiones tradicionales
  • sin necesidad de API stateless moderna

quizá unas sesiones clásicas te basten.

No todo proyecto necesita JWT + refresh tokens.

Pero si ya estás construyendo APIs modernas, aprender esto sí vale muchísimo la pena.


Conclusión:


Los refresh tokens son una pieza muy importante en autenticación moderna porque te permiten combinar dos cosas que normalmente chocan entre sí:

  • seguridad
  • experiencia de usuario

Gracias a ellos, puedes usar access tokens cortos y más seguros sin obligar al usuario a iniciar sesión constantemente.


La clave está en implementarlos con lógica:

  • diferenciarlos bien del access token
  • guardarlos en base de datos
  • poner expiración
  • permitir revocación
  • considerar rotación si buscas más seguridad

Si ya tienes login con JWT en PHP, este es uno de los siguientes pasos más valiosos para llevar tu backend a un nivel mucho más profesional.







Siguiente lectura recomendada: