En la guía anterior construimos una API REST con PHP, JSON y PDO. Ahora vamos a dar el siguiente paso: agregar autenticación por tokens para proteger los endpoints y saber qué usuario está realizando cada petición.
Vamos a implementar un sistema sencillo pero correcto: el usuario inicia sesión con email y contraseña, la API genera un token aleatorio, guarda solamente su hash en MySQL y devuelve el token original al cliente. Después el cliente lo envía en el header Authorization: Bearer ....
¿Qué vamos a construir?
- registro de usuarios;
- login mediante JSON;
- contraseñas con
password_hash(); - tokens aleatorios seguros;
- almacenamiento del hash del token;
- header
Authorization: Bearer; - endpoints públicos y privados;
- obtención del usuario autenticado;
- expiración de tokens;
- logout y revocación;
- diferencia entre HTTP 401 y 403;
- pruebas con Postman.
1. ¿Por qué una API necesita autenticación?
Sin autenticación, cualquier cliente que conozca una URL podría intentar acceder a operaciones sensibles. En una API necesitamos una forma de identificar al usuario en cada petición sin depender de una sesión HTML tradicional.
Un flujo habitual es:
- el usuario envía email y contraseña;
- la API verifica las credenciales;
- si son correctas, genera un token;
- el cliente guarda ese token;
- en cada petición protegida lo envía en
Authorization; - la API valida el token y recupera al usuario.
2. Estructura de las tablas
Vamos a utilizar una tabla de usuarios y otra tabla separada para los tokens.
CREATE TABLE usuarios ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, nombre VARCHAR(100) NOT NULL, email VARCHAR(150) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, rol ENUM('admin', 'usuario') NOT NULL DEFAULT 'usuario', activo TINYINT(1) NOT NULL DEFAULT 1, creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE api_tokens ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, usuario_id INT UNSIGNED NOT NULL, token_hash CHAR(64) NOT NULL UNIQUE, expira_en DATETIME NOT NULL, creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (usuario_id) REFERENCES usuarios(id) ON DELETE CASCADE );
3. Registrar usuarios de forma segura
La contraseña nunca debe guardarse en texto plano.
$passwordHash = password_hash(
$password,
PASSWORD_DEFAULT
);
$stmt = $pdo->prepare(
'INSERT INTO usuarios
(nombre, email, password)
VALUES (:nombre, :email, :password)'
);
$stmt->execute([
'nombre' => $nombre,
'email' => $email,
'password' => $passwordHash
]);
4. Endpoint de login
El cliente enviará una petición POST /api/login con JSON:
{
"email": "ana@ejemplo.com",
"password": "mi-clave-segura"
}
Primero buscamos el usuario por email.
$stmt = $pdo->prepare(
'SELECT *
FROM usuarios
WHERE email = :email
LIMIT 1'
);
$stmt->execute([
'email' => $email
]);
$usuario = $stmt->fetch();
5. Verificar la contraseña
if ( !$usuario || !$usuario['activo'] || !password_verify( $password, $usuario['password'] ) ) { responder([ 'success' => false, 'message' => 'Credenciales inválidas' ], 401); }
Usamos un mensaje genérico para no revelar si el email existe o no.
6. Generar un token seguro
Podemos generar 32 bytes aleatorios y convertirlos a hexadecimal:
$token = bin2hex(
random_bytes(32)
);
El resultado tendrá 64 caracteres hexadecimales y será extremadamente difícil de adivinar.
7. No guardar el token original
La base debería almacenar solamente un hash del token. Así, si alguien accede a la tabla, no obtiene credenciales utilizables directamente.
$tokenHash = hash(
'sha256',
$token
);
Después guardamos el hash y una fecha de expiración.
$expiraEn = (
new DateTimeImmutable(
'+1 day'
)
)->format('Y-m-d H:i:s');
$stmt = $pdo->prepare(
'INSERT INTO api_tokens
(usuario_id, token_hash, expira_en)
VALUES (:usuario_id, :token_hash, :expira_en)'
);
$stmt->execute([
'usuario_id' => $usuario['id'],
'token_hash' => $tokenHash,
'expira_en' => $expiraEn
]);
8. Respuesta del login
responder([
'success' => true,
'token' => $token,
'token_type' => 'Bearer',
'expires_at' => $expiraEn
]);
El token original se devuelve una sola vez al cliente. En la base queda únicamente su hash.
9. Enviar el Bearer token
Para acceder a un endpoint privado, el cliente agrega este header:
Authorization: Bearer 4fca9a...
En Postman se puede configurar desde Authorization → Bearer Token.
10. Leer el header Authorization en PHP
function obtenerBearerToken(): ?string { $header = $_SERVER[ 'HTTP_AUTHORIZATION' ] ?? ''; if (!preg_match( '/^Bearer\s+(.+)$/i', $header, $matches )) { return null; } return trim($matches[1]); }
11. Validar el token
Recibimos el token, calculamos su hash y buscamos una coincidencia vigente.
$token = obtenerBearerToken(); if (!$token) { responder([ 'success' => false, 'message' => 'Token requerido' ], 401); } $tokenHash = hash( 'sha256', $token ); $stmt = $pdo->prepare( 'SELECT u.id, u.nombre, u.email, u.rol FROM api_tokens t INNER JOIN usuarios u ON u.id = t.usuario_id WHERE t.token_hash = :token_hash AND t.expira_en > NOW() AND u.activo = 1 LIMIT 1' ); $stmt->execute([ 'token_hash' => $tokenHash ]); $usuario = $stmt->fetch();
12. Crear un middleware de autenticación
function autenticar(PDO $pdo): array { $token = obtenerBearerToken(); if (!$token) { responder([ 'success' => false, 'message' => 'No autenticado' ], 401); } $hash = hash('sha256', $token); $stmt = $pdo->prepare( 'SELECT u.id, u.nombre, u.email, u.rol FROM api_tokens t INNER JOIN usuarios u ON u.id = t.usuario_id WHERE t.token_hash = :hash AND t.expira_en > NOW() AND u.activo = 1 LIMIT 1' ); $stmt->execute([ 'hash' => $hash ]); $usuario = $stmt->fetch(); if (!$usuario) { responder([ 'success' => false, 'message' => 'Token inválido o vencido' ], 401); } return $usuario; }
13. Proteger un endpoint
Por ejemplo, para GET /api/me:
$usuario = autenticar($pdo);
responder([
'success' => true,
'data' => $usuario
]);
Si el token es válido, el endpoint ya conoce la identidad del usuario sin pedir nuevamente email y contraseña.
14. Endpoint sólo para administradores
$usuario = autenticar($pdo); if ($usuario['rol'] !== 'admin') { responder([ 'success' => false, 'message' => 'No autorizado' ], 403); }
15. Diferencia entre 401 y 403
| Código | Cuándo usarlo |
|---|---|
| 401 | No hay credenciales válidas: falta el token, es inválido o venció. |
| 403 | El usuario está autenticado, pero no tiene permiso para realizar esa acción. |
Esta diferencia es importante para que los clientes de la API sepan si deben volver a autenticarse o simplemente informar que el usuario no tiene permisos.
16. Logout: revocar el token actual
El logout de una API consiste en invalidar el token que estaba usando el cliente.
$token = obtenerBearerToken(); if ($token) { $hash = hash('sha256', $token); $stmt = $pdo->prepare( 'DELETE FROM api_tokens WHERE token_hash = :hash' ); $stmt->execute([ 'hash' => $hash ]); } responder([ 'success' => true, 'message' => 'Sesión cerrada' ]);
17. Eliminar tokens vencidos
Con el tiempo la tabla acumulará tokens expirados. Podemos limpiarlos periódicamente:
DELETE FROM api_tokens WHERE expira_en < NOW();
En producción esto podría ejecutarse mediante una tarea programada del servidor.
18. Probar todo con Postman
- POST /api/login → enviar email y contraseña.
- copiar el token de la respuesta.
- abrir un endpoint privado.
- seleccionar Authorization → Bearer Token.
- pegar el token.
- repetir la petición.
- probar sin token para comprobar el 401.
- probar con un usuario sin rol adecuado para comprobar el 403.
- ejecutar logout y volver a utilizar el mismo token: debería dejar de funcionar.
19. ¿Por qué no usamos JWT todavía?
JWT es muy popular, pero agrega conceptos adicionales: firma, claims, expiración embebida, claves, algoritmos y estrategias de revocación. Para aprender autenticación de APIs primero conviene entender el flujo con tokens opacos.
Los tokens almacenados en servidor tienen además una ventaja didáctica importante: podemos revocarlos inmediatamente eliminando una fila de la base.
20. Buenas prácticas de seguridad
- usar siempre HTTPS en producción;
- generar tokens con
random_bytes(); - guardar hashes y no tokens originales;
- definir expiración;
- permitir revocación;
- no registrar tokens completos en logs;
- no enviar tokens en parámetros GET;
- validar que el usuario siga activo;
- limitar intentos de login;
- evitar mensajes que revelen si una cuenta existe;
- rotar o revocar credenciales cuando sea necesario.
Flujo completo
- el cliente envía credenciales a
/api/login; - la API valida email y contraseña;
- genera un token aleatorio;
- guarda su hash con una fecha de expiración;
- devuelve el token original;
- el cliente lo envía como Bearer token;
- el middleware calcula su hash;
- busca un token vigente;
- recupera al usuario;
- el endpoint decide si además necesita un rol o permiso específico.
¿Qué aprendimos?
Agregamos autenticación real a nuestra API REST con PHP. Ahora podemos identificar usuarios, proteger rutas, manejar roles, expirar credenciales y revocar sesiones sin depender de cookies ni sesiones tradicionales del navegador.
Esta base sirve para entender cómo trabajan soluciones más completas de frameworks como Laravel Sanctum o Passport.
Siguiente paso
El próximo avance natural es organizar esta API en una arquitectura más completa y después trasladar los mismos conceptos a Laravel: rutas, controladores, validación, modelos, middleware y autenticación.
Seguí aprendiendo PHP
Aprender PHP desde cero: ruta completa →
¿Te sirvió esta guía? ☕
Si este contenido te ayudó y querés apoyar a Club Programador para seguir publicando guías, ejercicios y proyectos gratuitos, podés colaborar mediante:
Descubre más desde Club Programador
Suscríbete y recibe las últimas entradas en tu correo electrónico.