Autenticación en una API REST con PHP: login, Bearer tokens, roles y endpoints protegidos


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:

  1. el usuario envía email y contraseña;
  2. la API verifica las credenciales;
  3. si son correctas, genera un token;
  4. el cliente guarda ese token;
  5. en cada petición protegida lo envía en Authorization;
  6. 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ódigoCuándo usarlo
401No hay credenciales válidas: falta el token, es inválido o venció.
403El 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

  1. el cliente envía credenciales a /api/login;
  2. la API valida email y contraseña;
  3. genera un token aleatorio;
  4. guarda su hash con una fecha de expiración;
  5. devuelve el token original;
  6. el cliente lo envía como Bearer token;
  7. el middleware calcula su hash;
  8. busca un token vigente;
  9. recupera al usuario;
  10. 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:

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal


Descubre más desde Club Programador

Suscríbete y recibe las últimas entradas en tu correo electrónico.

Deja una respuesta

Descubre más desde Club Programador

Suscríbete ahora para seguir leyendo y obtener acceso al archivo completo.

Seguir leyendo