PHP

Autenticação JWT Já leu

12 min de leitura

Autenticação JWT
Autenticação é o processo de verificar quem é o cliente. Em APIs REST stateless, não existe sessão no servidor — cada requisição deve provar sua identidade. JWT (JSON Web Token) é o padrão dominante para isso: um token a

Autenticação é o processo de verificar quem é o cliente. Em APIs REST stateless, não existe sessão no servidor — cada requisição deve provar sua identidade. JWT (JSON Web Token) é o padrão dominante para isso: um token autocontido que carrega claims (afirmações sobre o usuário), assinado criptograficamente para que o servidor possa verificar sua autenticidade sem consultar o banco de dados.

Um JWT tem três partes separadas por pontos: o header (algoritmo), o payload (dados) e a assinatura. O servidor assina o token com uma chave secreta na emissão; na verificação, recalcula a assinatura e compara — se bater, o token é válido. Se qualquer bit do payload for alterado, a assinatura não bate e o token é rejeitado.

Neste artigo implementamos JWT do zero para entender o mecanismo, depois usamos a biblioteca firebase/php-jwt para produção, construímos refresh tokens, e criamos um middleware de autenticação reutilizável.


Anatomia de um JWT

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9   ← Header  (base64url)
.
eyJzdWIiOiI0MiIsIm5hbWUiOiJBbmEiLCJpYXQiOjE3MDAwMDAwMDB9  ← Payload (base64url)
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c   ← Assinatura (HMAC-SHA256)
<?php
declare(strict_types=1);

// ── Decodificando manualmente para entender a estrutura ───────────────

$token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
       . '.eyJzdWIiOiI0MiIsIm5hbWUiOiJBbmEiLCJpYXQiOjE3MDAwMDAwMDB9'
       . '.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';

[$headerB64, $payloadB64, $assinaturaB64] = explode('.', $token);

// base64url → base64 padrão → decodificar
function base64urlDecode(string $input): string
{
    $b64 = strtr($input, '-_', '+/');
    $b64 = str_pad($b64, strlen($b64) + (4 - strlen($b64) % 4) % 4, '=');
    return base64_decode($b64);
}

$header    = json_decode(base64urlDecode($headerB64),    associative: true);
$payload   = json_decode(base64urlDecode($payloadB64),   associative: true);

// $header  = ['alg' => 'HS256', 'typ' => 'JWT']
// $payload = ['sub' => '42', 'name' => 'Ana', 'iat' => 1700000000]

// ── Claims padrão do JWT ──────────────────────────────────────────────
// iss (issuer)    — quem emitiu o token
// sub (subject)   — a quem o token se refere (geralmente o ID do usuário)
// aud (audience)  — para quem o token é destinado
// exp (expiration)— timestamp Unix de expiração — SEMPRE definir
// iat (issued at) — timestamp Unix de emissão
// jti (JWT ID)    — ID único do token — útil para revogação

Implementação JWT do zero (para entender)

<?php
declare(strict_types=1);

class JwtManual
{
    public static function assinar(array $payload, string $chave): string
    {
        $header = ['alg' => 'HS256', 'typ' => 'JWT'];

        $headerB64  = self::base64urlEncode(json_encode($header));
        $payloadB64 = self::base64urlEncode(json_encode($payload));

        $mensagem   = "{$headerB64}.{$payloadB64}";
        $assinatura = hash_hmac('sha256', $mensagem, $chave, binary: true);

        return "{$mensagem}." . self::base64urlEncode($assinatura);
    }

    public static function verificar(string $token, string $chave): array
    {
        $partes = explode('.', $token);
        if (count($partes) !== 3) {
            throw new \InvalidArgumentException('Token JWT malformado.');
        }

        [$headerB64, $payloadB64, $assinaturaB64] = $partes;

        // Recalcula a assinatura esperada
        $mensagem            = "{$headerB64}.{$payloadB64}";
        $assinaturaEsperada  = hash_hmac('sha256', $mensagem, $chave, binary: true);
        $assinaturaRecebida  = self::base64urlDecode($assinaturaB64);

        // Comparação segura — evita timing attacks
        if (!hash_equals($assinaturaEsperada, $assinaturaRecebida)) {
            throw new \RuntimeException('Assinatura JWT inválida.');
        }

        $payload = json_decode(self::base64urlDecode($payloadB64), associative: true);

        // Verifica expiração
        if (isset($payload['exp']) && $payload['exp'] < time()) {
            throw new \RuntimeException('Token JWT expirado.');
        }

        return $payload;
    }

    private static function base64urlEncode(string $input): string
    {
        return rtrim(strtr(base64_encode($input), '+/', '-_'), '=');
    }

    private static function base64urlDecode(string $input): string
    {
        $b64 = strtr($input, '-_', '+/');
        $b64 = str_pad($b64, strlen($b64) + (4 - strlen($b64) % 4) % 4, '=');
        return base64_decode($b64);
    }
}

// Teste
$payload = ['sub' => '42', 'nome' => 'Ana', 'exp' => time() + 3600, 'iat' => time()];
$token   = JwtManual::assinar($payload, 'minha-chave-secreta');
$dados   = JwtManual::verificar($token, 'minha-chave-secreta'); // retorna $payload

JWT com firebase/php-jwt (produção)

Para produção, usamos a biblioteca firebase/php-jwt que implementa todos os algoritmos e edge cases corretamente.

<?php
declare(strict_types=1);

// composer require firebase/php-jwt

use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
use Firebase\JWT\SignatureInvalidException;
use Firebase\JWT\BeforeValidException;

class ServicoJWT
{
    // Usar RS256 (RSA) em produção é mais seguro que HS256 (HMAC)
    // porque a chave de verificação (pública) pode ser compartilhada sem expor a chave de assinatura
    // Para simplicidade, usamos HS256 aqui
    private const ALGORITMO = 'HS256';
    private const TTL_ACCESS  = 900;     // 15 minutos — token de acesso curto
    private const TTL_REFRESH = 604800;  // 7 dias — refresh token longo

    public function __construct(
        private readonly string $chaveSecreta, // mínimo 32 caracteres, variável de ambiente
        private readonly string $issuer = 'meuapp.com.br',
    ) {}

    public function emitirTokenDeAcesso(int $usuarioId, string $perfil, string $email): string
    {
        $agora = time();
        $payload = [
            'iss'    => $this->issuer,
            'sub'    => (string) $usuarioId,
            'iat'    => $agora,
            'exp'    => $agora + self::TTL_ACCESS,
            'perfil' => $perfil,
            'email'  => $email,
            'tipo'   => 'access',
        ];
        return JWT::encode($payload, $this->chaveSecreta, self::ALGORITMO);
    }

    public function emitirRefreshToken(int $usuarioId): string
    {
        $agora = time();
        $payload = [
            'iss'  => $this->issuer,
            'sub'  => (string) $usuarioId,
            'iat'  => $agora,
            'exp'  => $agora + self::TTL_REFRESH,
            'jti'  => bin2hex(random_bytes(16)), // ID único para revogação
            'tipo' => 'refresh',
        ];
        return JWT::encode($payload, $this->chaveSecreta, self::ALGORITMO);
    }

    public function verificar(string $token): object
    {
        return JWT::decode($token, new Key($this->chaveSecreta, self::ALGORITMO));
    }

    public function verificarComTratamento(string $token): array
    {
        try {
            $payload = $this->verificar($token);
            return ['valido' => true, 'payload' => $payload, 'erro' => null];
        } catch (ExpiredException $e) {
            return ['valido' => false, 'payload' => null, 'erro' => 'token_expirado'];
        } catch (SignatureInvalidException $e) {
            return ['valido' => false, 'payload' => null, 'erro' => 'assinatura_invalida'];
        } catch (BeforeValidException $e) {
            return ['valido' => false, 'payload' => null, 'erro' => 'token_ainda_nao_valido'];
        } catch (\Exception $e) {
            return ['valido' => false, 'payload' => null, 'erro' => 'token_invalido'];
        }
    }
}

Controller de autenticação — login, refresh e logout

<?php
declare(strict_types=1);

class AuthController
{
    public function __construct(
        private readonly ServicoJWT           $jwt,
        private readonly UsuarioRepositorioInterface $usuarios,
        private readonly RefreshTokenRepositorio     $refreshTokens,
    ) {}

    // POST /api/auth/login
    public function login(array $params): void
    {
        $corpo = $this->lerCorpo();

        if (empty($corpo['email']) || empty($corpo['senha'])) {
            Resposta::badRequest('Email e senha são obrigatórios.');
        }

        $usuario = $this->usuarios->buscarPorEmail($corpo['email']);

        // Sempre verificar com password_verify mesmo se usuário não existe
        // (evita timing attack que revela quais emails estão cadastrados)
        $senhaCorreta = $usuario !== null
            && password_verify($corpo['senha'], $usuario->senhaHash);

        if (!$senhaCorreta) {
            Resposta::erro(401, 'Credenciais inválidas.');
        }

        if (!$usuario->ativo) {
            Resposta::erro(403, 'Conta desativada. Entre em contato com o suporte.');
        }

        // Emite os dois tokens
        $accessToken  = $this->jwt->emitirTokenDeAcesso($usuario->id, $usuario->perfil, $usuario->email);
        $refreshToken = $this->jwt->emitirRefreshToken($usuario->id);

        // Persiste o refresh token (necessário para revogação)
        $this->refreshTokens->salvar(
            usuarioId: $usuario->id,
            token:     $refreshToken,
            expiresAt: new \DateTimeImmutable('+7 days'),
        );

        Resposta::ok([
            'access_token'  => $accessToken,
            'refresh_token' => $refreshToken,
            'tipo'          => 'Bearer',
            'expira_em'     => 900, // segundos
            'usuario' => [
                'id'     => $usuario->id,
                'nome'   => $usuario->nome,
                'email'  => $usuario->email,
                'perfil' => $usuario->perfil,
            ],
        ]);
    }

    // POST /api/auth/refresh
    public function refresh(array $params): void
    {
        $corpo = $this->lerCorpo();

        if (empty($corpo['refresh_token'])) {
            Resposta::badRequest('refresh_token é obrigatório.');
        }

        $resultado = $this->jwt->verificarComTratamento($corpo['refresh_token']);

        if (!$resultado['valido']) {
            Resposta::erro(401, 'Refresh token inválido ou expirado.');
        }

        $payload = $resultado['payload'];

        // Verifica se é um refresh token (não um access token)
        if (($payload->tipo ?? '') !== 'refresh') {
            Resposta::erro(401, 'Token inválido para esta operação.');
        }

        // Verifica se o token não foi revogado no banco
        if (!$this->refreshTokens->existe($corpo['refresh_token'])) {
            Resposta::erro(401, 'Refresh token revogado.');
        }

        $usuarioId = (int) $payload->sub;
        $usuario   = $this->usuarios->buscarPorId($usuarioId);

        if ($usuario === null || !$usuario->ativo) {
            Resposta::erro(401, 'Usuário não encontrado ou desativado.');
        }

        // Rotação de refresh token — invalida o antigo, emite novo
        $this->refreshTokens->revogar($corpo['refresh_token']);
        $novoRefreshToken = $this->jwt->emitirRefreshToken($usuario->id);
        $this->refreshTokens->salvar(
            usuarioId: $usuario->id,
            token:     $novoRefreshToken,
            expiresAt: new \DateTimeImmutable('+7 days'),
        );

        Resposta::ok([
            'access_token'  => $this->jwt->emitirTokenDeAcesso($usuario->id, $usuario->perfil, $usuario->email),
            'refresh_token' => $novoRefreshToken,
            'tipo'          => 'Bearer',
            'expira_em'     => 900,
        ]);
    }

    // POST /api/auth/logout
    public function logout(array $params): void
    {
        $corpo = $this->lerCorpo();

        if (!empty($corpo['refresh_token'])) {
            $this->refreshTokens->revogar($corpo['refresh_token']);
        }

        Resposta::semConteudo();
    }

    private function lerCorpo(): array
    {
        $raw   = file_get_contents('php://input');
        $dados = json_decode($raw ?: '', associative: true);
        if (json_last_error() !== JSON_ERROR_NONE) Resposta::badRequest('JSON inválido.');
        return $dados ?? [];
    }
}

Middleware de autenticação

<?php
declare(strict_types=1);

class MiddlewareAutenticacao
{
    public function __construct(
        private readonly ServicoJWT $jwt,
    ) {}

    // Middleware que extrai e valida o Bearer token
    public function autenticar(array $params, callable $proximo): void
    {
        $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';

        if (!str_starts_with($authHeader, 'Bearer ')) {
            Resposta::naoAutorizado('Token de autenticação não fornecido.');
        }

        $token     = substr($authHeader, 7); // remove "Bearer "
        $resultado = $this->jwt->verificarComTratamento($token);

        if (!$resultado['valido']) {
            $mensagens = [
                'token_expirado'          => 'Token expirado. Faça refresh ou login novamente.',
                'assinatura_invalida'     => 'Token com assinatura inválida.',
                'token_ainda_nao_valido'  => 'Token ainda não é válido.',
                'token_invalido'          => 'Token inválido.',
            ];
            Resposta::naoAutorizado($mensagens[$resultado['erro']] ?? 'Token inválido.');
        }

        $payload = $resultado['payload'];

        // Verifica se é um access token (não refresh token)
        if (($payload->tipo ?? '') !== 'access') {
            Resposta::naoAutorizado('Use o access token para autenticar requisições.');
        }

        // Injeta o usuário autenticado na superglobal de requisição
        // (em frameworks reais, isto vai para um objeto Request)
        $_REQUEST['usuario_autenticado'] = [
            'id'     => (int) $payload->sub,
            'perfil' => $payload->perfil,
            'email'  => $payload->email,
        ];

        $proximo($params);
    }

    // Middleware de autorização por perfil
    public function exigirPerfil(string ...$perfisPermitidos): callable
    {
        return function (array $params, callable $proximo) use ($perfisPermitidos): void {
            $usuario = $_REQUEST['usuario_autenticado'] ?? null;

            if ($usuario === null) {
                Resposta::naoAutorizado();
            }

            if (!in_array($usuario['perfil'], $perfisPermitidos, true)) {
                Resposta::proibido(
                    "Perfil '{$usuario['perfil']}' não tem permissão para esta operação."
                );
            }

            $proximo($params);
        };
    }
}

// ── Usando os middlewares no router ───────────────────────────────────
$auth    = new MiddlewareAutenticacao($jwt);
$router  = new Router();

// Rotas públicas — sem autenticação
$router->post('/api/auth/login',   [$authController, 'login']);
$router->post('/api/auth/refresh', [$authController, 'refresh']);
$router->post('/api/auth/logout',  [$authController, 'logout']);
$router->get ('/api/produtos',     [$produtoController, 'listar']);

// Rotas autenticadas
$router->post('/api/produtos', function(array $params) use ($auth, $produtoController): void {
    $auth->autenticar($params, fn($p) => $produtoController->criar($p));
});

// Rota apenas para admins
$router->delete('/api/usuarios/{id}', function(array $params) use ($auth, $usuarioController): void {
    $auth->autenticar($params, function($p) use ($auth, $usuarioController): void {
        $auth->exigirPerfil('admin')($p, fn($pp) => $usuarioController->deletar($pp));
    });
});

Resumo do artigo

Conceito O que aprendemos
Estrutura JWT Header + Payload + Assinatura em base64url — autocontido e verificável
Claims padrão sub, iss, exp, iat, jti — sempre definir exp
HMAC-SHA256 Assinatura simétrica — mesma chave para assinar e verificar
hash_equals() Comparação resistente a timing attacks — nunca use == para tokens
Access token TTL curto (15min) — carrega dados do usuário — não precisa de banco
Refresh token TTL longo (7 dias) — armazenado no banco — permite rotação e revogação
Rotação de refresh Cada refresh emite um novo refresh token e invalida o anterior
Middleware Extrai e valida token — injeta usuário autenticado no contexto
Autorização Middleware separado verifica perfil após autenticação

Exercício da semana

  1. Implemente o RefreshTokenRepositorio com PDO: tabela refresh_tokens com colunas token (hash SHA-256 — nunca armazene o token em texto puro), usuario_id, expires_at, revogado_em. Métodos: salvar(), existe(), revogar(), limparExpirados().

  2. Adicione ao middleware um mecanismo de token blacklist em memória (usando APCu): quando o logout é chamado, armazene o jti do access token no APCu pelo tempo restante até expirar. O middleware verifica se o jti está na blacklist antes de autorizar.

  3. Implemente o fluxo de troca de senha: POST /api/auth/trocar-senha recebe o token de acesso atual + senha atual + nova senha. Após trocar, revogue todos os refresh tokens do usuário (logout de todos os dispositivos).

  4. Adicione rate limiting por IP no endpoint de login: máximo de 5 tentativas falhas em 15 minutos, usando APCu para controle. Após exceder, retornar 429 Too Many Requests com header Retry-After.

  5. Desafio: implemente autenticação com RS256 (RSA): gere um par de chaves com openssl_pkey_new(), assine com a chave privada e verifique com a pública. Adapte o ServicoJWT para suportar ambos os algoritmos, configurável via construtor.


Referências

  • JWT.io — Debugger e especificação: https://jwt.io
  • RFC 7519 — JSON Web Token: https://datatracker.ietf.org/doc/html/rfc7519
  • firebase/php-jwt: https://github.com/firebase/php-jwt
  • OWASP — JWT Security Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html
  • PHP Manual — password_hash / password_verify: https://www.php.net/manual/pt_BR/function.password-hash.php
Comentários

Mais em PHP

Artigo 42 — Eloquent ORM com Laravel
Artigo 42 — Eloquent ORM com Laravel

Eloquent é o ORM do Laravel, e é um dos mais elegantes disponíveis em qualque...

Cache Avançado com Redis
Cache Avançado com Redis

Cache é a diferença entre uma query de 200ms que roda mil vezes por minuto e...

Interfaces Avançadas
Interfaces Avançadas

Interfaces s&atilde;o o principal mecanismo de abstra&ccedil;&atilde;o do PHP...