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

API REST con PHP desde cero: GET, POST, PUT, DELETE, JSON y PDO

En esta guía vamos a construir una API REST con PHP desde cero utilizando JSON, los métodos HTTP GET, POST, PUT y DELETE, códigos de estado y PDO para trabajar con MySQL.

La idea es entender cómo funciona una API por dentro antes de pasar a frameworks como Laravel. Al finalizar vas a tener una API capaz de listar, consultar, crear, modificar y eliminar registros desde clientes como Postman, JavaScript, aplicaciones móviles u otros sistemas.

¿Qué vamos a construir?

  • una API que devuelva respuestas JSON;
  • endpoint GET para listar usuarios;
  • endpoint GET para consultar un usuario por ID;
  • endpoint POST para crear usuarios;
  • endpoint PUT para modificarlos;
  • endpoint DELETE para eliminarlos;
  • validación de datos;
  • consultas preparadas con PDO;
  • códigos HTTP correctos;
  • manejo básico de errores;
  • pruebas desde Postman.

Antes de empezar

Conviene haber visto previamente estas guías:

1. ¿Qué es una API REST?

Una API permite que dos aplicaciones intercambien información. En una API REST los recursos se identifican mediante URLs y las acciones se expresan principalmente a través de métodos HTTP.

MétodoUso habitualEjemplo
GETConsultar datos/api/usuarios
POSTCrear un recurso/api/usuarios
PUTActualizar un recurso/api/usuarios/5
DELETEEliminar un recurso/api/usuarios/5

Una API no debería depender de páginas HTML. En este caso recibirá y devolverá información en formato JSON.

2. Estructura del proyecto

api-php/
├── config/
│   └── database.php
├── src/
│   ├── UsuarioRepository.php
│   └── response.php
└── public/
    └── index.php

Para mantener el ejemplo claro vamos a utilizar un único punto de entrada público y separar la conexión y el acceso a datos.

3. Crear la base de datos

CREATE DATABASE api_php
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;

USE api_php;

CREATE TABLE usuarios (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(100) NOT NULL,
    email VARCHAR(150) NOT NULL UNIQUE,
    activo TINYINT(1) NOT NULL DEFAULT 1,
    creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

4. Conexión con PDO

Archivo config/database.php:

<?php

return new PDO(
    'mysql:host=localhost;dbname=api_php;charset=utf8mb4',
    'root',
    '',
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]
);

5. Responder siempre en JSON

Podemos crear una función común para todas las respuestas.

<?php

function responder(
    array $datos,
    int $status = 200
): never {
    http_response_code($status);

    header('Content-Type: application/json; charset=utf-8');

    echo json_encode(
        $datos,
        JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
    );

    exit;
}

Así evitamos repetir encabezados, código de estado y json_encode() en cada endpoint.

6. Leer el método HTTP y la URL

$metodo = $_SERVER['REQUEST_METHOD'];

$ruta = parse_url(
    $_SERVER['REQUEST_URI'],
    PHP_URL_PATH
);

$segmentos = array_values(
    array_filter(explode('/', $ruta))
);

Para una URL como /api/usuarios/5, los segmentos serán aproximadamente:

[
    'api',
    'usuarios',
    '5'
]

7. Repositorio de usuarios

<?php

class UsuarioRepository
{
    public function __construct(
        private PDO $pdo
    ) {
    }

    public function todos(): array
    {
        $stmt = $this->pdo->query(
            'SELECT id, nombre, email, activo, creado_en
             FROM usuarios
             ORDER BY id DESC'
        );

        return $stmt->fetchAll();
    }

    public function buscar(int $id): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, nombre, email, activo, creado_en
             FROM usuarios
             WHERE id = :id'
        );

        $stmt->execute(['id' => $id]);

        $usuario = $stmt->fetch();

        return $usuario ?: null;
    }
}

8. GET: listar usuarios

Para GET /api/usuarios:

if (
    $metodo === 'GET'
    && count($segmentos) === 2
) {
    $usuarios = $repository->todos();

    responder([
        'success' => true,
        'data' => $usuarios
    ]);
}

Una respuesta posible sería:

{
    "success": true,
    "data": [
        {
            "id": 1,
            "nombre": "Ana",
            "email": "ana@ejemplo.com"
        }
    ]
}

9. GET: consultar un usuario por ID

if (
    $metodo === 'GET'
    && count($segmentos) === 3
) {
    $id = filter_var(
        $segmentos[2],
        FILTER_VALIDATE_INT
    );

    if (!$id) {
        responder([
            'success' => false,
            'message' => 'ID inválido'
        ], 400);
    }

    $usuario = $repository->buscar($id);

    if (!$usuario) {
        responder([
            'success' => false,
            'message' => 'Usuario no encontrado'
        ], 404);
    }

    responder([
        'success' => true,
        'data' => $usuario
    ]);
}

10. POST: crear un usuario

Cuando enviamos JSON, PHP no lo coloca automáticamente en $_POST. Debemos leer el cuerpo crudo de la petición.

$datos = json_decode(
    file_get_contents('php://input'),
    true
);

if (!is_array($datos)) {
    responder([
        'success' => false,
        'message' => 'JSON inválido'
    ], 400);
}

Después validamos los campos:

$nombre = trim($datos['nombre'] ?? '');
$email = trim($datos['email'] ?? '');

if ($nombre === '') {
    responder([
        'success' => false,
        'message' => 'El nombre es obligatorio'
    ], 422);
}

if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    responder([
        'success' => false,
        'message' => 'El email no es válido'
    ], 422);
}

La inserción se realiza mediante una consulta preparada:

$stmt = $pdo->prepare(
    'INSERT INTO usuarios (nombre, email)
     VALUES (:nombre, :email)'
);

$stmt->execute([
    'nombre' => $nombre,
    'email' => $email
]);

$id = (int) $pdo->lastInsertId();

responder([
    'success' => true,
    'message' => 'Usuario creado',
    'id' => $id
], 201);

11. PUT: actualizar un usuario

$stmt = $pdo->prepare(
    'UPDATE usuarios
     SET nombre = :nombre,
         email = :email,
         activo = :activo
     WHERE id = :id'
);

$stmt->execute([
    'nombre' => $nombre,
    'email' => $email,
    'activo' => $activo,
    'id' => $id
]);

responder([
    'success' => true,
    'message' => 'Usuario actualizado'
]);

Antes de ejecutar el UPDATE conviene verificar que el recurso exista. De lo contrario una petición podría responder “actualizado” aunque el ID nunca haya existido.

12. DELETE: eliminar un usuario

$stmt = $pdo->prepare(
    'DELETE FROM usuarios WHERE id = :id'
);

$stmt->execute([
    'id' => $id
]);

responder([
    'success' => true,
    'message' => 'Usuario eliminado'
]);

En sistemas reales puede convenir una baja lógica utilizando un campo como activo o deleted_at, especialmente cuando se necesita conservar historial.

13. Códigos HTTP que conviene usar

CódigoSignificado
200Petición correcta
201Recurso creado
204Respuesta correcta sin contenido
400Petición inválida
401No autenticado
403No autorizado
404Recurso no encontrado
409Conflicto, por ejemplo email duplicado
422Datos válidos como JSON pero incorrectos para la operación
500Error interno del servidor

14. Manejar errores de base de datos

try {
    // ejecutar operación
} catch (PDOException $e) {
    responder([
        'success' => false,
        'message' => 'Error interno del servidor'
    ], 500);
}

En producción no conviene devolver al cliente el mensaje completo de la excepción porque podría revelar nombres de tablas, columnas o detalles de infraestructura. Ese detalle debería registrarse en logs.

15. Router completo simplificado

if ($segmentos[0] ?? null !== 'api') {
    responder([
        'success' => false,
        'message' => 'Ruta no encontrada'
    ], 404);
}

if (($segmentos[1] ?? null) !== 'usuarios') {
    responder([
        'success' => false,
        'message' => 'Recurso no encontrado'
    ], 404);
}

$id = isset($segmentos[2])
    ? (int) $segmentos[2]
    : null;

switch ($metodo) {
    case 'GET':
        // listar o buscar por ID
        break;

    case 'POST':
        // crear
        break;

    case 'PUT':
        // actualizar
        break;

    case 'DELETE':
        // eliminar
        break;

    default:
        responder([
            'success' => false,
            'message' => 'Método no permitido'
        ], 405);
}

16. Probar la API con Postman

Supongamos que el proyecto corre en http://localhost/api-php/public.

  • GET /api/usuarios → listar.
  • GET /api/usuarios/1 → consultar un usuario.
  • POST /api/usuarios → crear.
  • PUT /api/usuarios/1 → actualizar.
  • DELETE /api/usuarios/1 → eliminar.

Para POST y PUT seleccioná Body → raw → JSON y enviá algo como:

{
    "nombre": "Juan Pérez",
    "email": "juan@ejemplo.com"
}

17. ¿Qué pasa con CORS?

Si un frontend JavaScript se ejecuta desde otro origen, el navegador puede bloquear la petición por la política de mismo origen. Para desarrollo podríamos permitir un origen concreto:

header(
    'Access-Control-Allow-Origin: https://mi-frontend.com'
);

header(
    'Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'
);

header(
    'Access-Control-Allow-Headers: Content-Type, Authorization'
);

No conviene usar * indiscriminadamente en producción cuando la API maneja credenciales o información sensible.

18. Buenas prácticas básicas

  • usar HTTPS en producción;
  • validar todos los datos recibidos;
  • usar consultas preparadas;
  • no devolver excepciones completas al cliente;
  • usar códigos HTTP coherentes;
  • mantener una estructura JSON consistente;
  • separar acceso a datos de la lógica HTTP;
  • registrar errores en logs;
  • versionar la API cuando empiece a crecer, por ejemplo /api/v1/usuarios;
  • agregar autenticación antes de exponer operaciones sensibles.

19. ¿Cómo quedaría una respuesta consistente?

Éxito:

{
    "success": true,
    "data": {
        "id": 1,
        "nombre": "Juan Pérez"
    }
}

Error:

{
    "success": false,
    "message": "Usuario no encontrado"
}

20. Siguiente paso: autenticación

Esta API todavía es pública. Cualquier cliente que conozca la URL podría intentar crear, modificar o eliminar datos. El siguiente paso natural es implementar autenticación por token y proteger determinados endpoints.

Eso nos permitirá trabajar conceptos como:

  • login mediante API;
  • generación de tokens;
  • header Authorization;
  • endpoints públicos y privados;
  • expiración o revocación de tokens;
  • permisos por usuario o rol.

¿Qué aprendimos?

Construimos la base de una API REST con PHP puro y MySQL. Vimos cómo interpretar métodos HTTP, leer JSON, devolver respuestas JSON, utilizar códigos de estado, validar datos y ejecutar operaciones CRUD mediante PDO.

Entender este flujo antes de utilizar un framework permite comprender mejor qué hacen Laravel, Symfony u otras herramientas cuando construimos APIs más grandes.

Seguí aprendiendo PHP

Podés recorrer toda la ruta desde:

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