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
-
Implemente o
RefreshTokenRepositoriocom PDO: tabelarefresh_tokenscom colunastoken(hash SHA-256 — nunca armazene o token em texto puro),usuario_id,expires_at,revogado_em. Métodos:salvar(),existe(),revogar(),limparExpirados(). -
Adicione ao middleware um mecanismo de token blacklist em memória (usando APCu): quando o logout é chamado, armazene o
jtido access token no APCu pelo tempo restante até expirar. O middleware verifica se ojtiestá na blacklist antes de autorizar. -
Implemente o fluxo de troca de senha:
POST /api/auth/trocar-senharecebe 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). -
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 Requestscom headerRetry-After. -
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 oServicoJWTpara 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