Antes de escrever qualquer código, vale ser honesto sobre algo que a maioria dos artigos ignora: JWT e OAuth2 não são a mesma coisa, e você provavelmente não precisa de OAuth2.
OAuth2 é um protocolo de delegação de autorização — serve para deixar um sistema acessar recursos de outro em nome do usuário. O "entrar com Google" do seu site é OAuth2. Mas se você está construindo o login da sua própria aplicação, o que você precisa é de autenticação baseada em JWT, que pode (ou não) usar o fluxo do OAuth2 internamente.
Este artigo cobre os dois cenários mais comuns no mercado brasileiro:
- Cenário A: Backend PHP rodando em host compartilhado (sem Redis, sem Node, sem Docker)
- Cenário B: Frontend JavaScript (React, Vue, Angular ou vanilla) consumindo qualquer API com JWT
O que o JWT é — e o que ele não é
Um JWT é um token assinado digitalmente. É composto por três partes separadas por pontos:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3MTQwMDE2MDB9.xK9...
HEADER PAYLOAD SIGNATURE
- Header: algoritmo de assinatura (
HS256,RS256) - Payload: dados do usuário (
sub,exp,roles) — não criptografado, apenas encoded em base64 - Signature: garante que o token não foi adulterado
O que isso significa na prática:
- O payload é legível por qualquer pessoa que tenha o token — nunca coloque senha, número de cartão ou dados sensíveis
- A assinatura garante integridade, não confidencialidade
- Um JWT válido não pode ser invalidado antes de expirar — a menos que você implemente um mecanismo extra
Cenário A: PHP em host compartilhado
Host compartilhado significa: sem Redis, sem processos persistentes, sem Node.js. Apenas PHP + MySQL (ou outro banco) + Apache/Nginx. É a realidade de milhares de projetos no Brasil.
A boa notícia: JWT funciona bem nesse ambiente. A compensação: revogação de tokens requer o banco de dados.
Instalação
composer require firebase/php-jwt
Se não tiver Composer no host, baixe o autoloader manualmente ou copie os arquivos da biblioteca. Em 2025, praticamente todos os hosts compartilhados respeitáveis suportam Composer via SSH.
Estrutura do projeto
api/
├── config/
│ └── jwt.php # constantes e configuração
├── src/
│ ├── JwtHelper.php # geração e verificação de tokens
│ └── AuthMiddleware.php # proteção de rotas
├── routes/
│ ├── login.php
│ ├── refresh.php
│ └── logout.php
└── .env # nunca commitar no Git
Configuração (.env e config/jwt.php)
# .env — nunca vá para o repositório
JWT_SECRET=gere_uma_chave_forte_com_no_minimo_32_caracteres_aqui
JWT_ACCESS_TTL=900 # 15 minutos em segundos
JWT_REFRESH_TTL=604800 # 7 dias em segundos
DB_HOST=localhost
DB_NAME=meu_banco
DB_USER=usuario
DB_PASS=senha
<?php
// config/jwt.php
// Carrega o .env de forma simples, sem biblioteca
$envFile = __DIR__ . '/../.env';
if (file_exists($envFile)) {
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (strpos(trim($line), '#') === 0) continue;
[$key, $value] = explode('=', $line, 2);
$_ENV[trim($key)] = trim($value);
}
}
define('JWT_SECRET', $_ENV['JWT_SECRET'] ?? '');
define('JWT_ACCESS_TTL', (int)($_ENV['JWT_ACCESS_TTL'] ?? 900));
define('JWT_REFRESH_TTL', (int)($_ENV['JWT_REFRESH_TTL'] ?? 604800));
if (empty(JWT_SECRET) || strlen(JWT_SECRET) < 32) {
http_response_code(500);
die(json_encode(['error' => 'Configuração de segurança inválida']));
}
JwtHelper.php — geração e verificação
<?php
// src/JwtHelper.php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
use Firebase\JWT\SignatureInvalidException;
class JwtHelper
{
/**
* Gera um access token de vida curta (15 minutos por padrão).
* Inclui apenas o necessário no payload — sem dados sensíveis.
*/
public static function generateAccessToken(int $userId, string $role = 'user'): string
{
$now = time();
$payload = [
'iss' => $_SERVER['HTTP_HOST'] ?? 'api', // quem emitiu
'sub' => (string)$userId, // subject: ID do usuário
'iat' => $now, // emitido em
'exp' => $now + JWT_ACCESS_TTL, // expira em
'jti' => bin2hex(random_bytes(8)), // ID único do token
'role' => $role,
];
return JWT::encode($payload, JWT_SECRET, 'HS256');
}
/**
* Verifica e decodifica um access token.
* Retorna o payload ou lança exceção.
*/
public static function verifyAccessToken(string $token): object
{
// Exceções específicas para respostas diferentes ao cliente
return JWT::decode($token, new Key(JWT_SECRET, 'HS256'));
}
/**
* Extrai o token do header Authorization: Bearer <token>
*/
public static function extractFromHeader(): ?string
{
$header = $_SERVER['HTTP_AUTHORIZATION']
?? apache_request_headers()['Authorization']
?? null;
if (!$header || !str_starts_with($header, 'Bearer ')) {
return null;
}
return substr($header, 7);
}
}
Tabela de refresh tokens no banco
-- Rodando no MySQL do host compartilhado
CREATE TABLE refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(64) NOT NULL UNIQUE, -- hash SHA-256 do token real
user_id INT NOT NULL,
expires_at DATETIME NOT NULL,
revoked TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_token (token),
INDEX idx_user (user_id),
INDEX idx_expires (expires_at)
);
Por que guardar o hash e não o token? Se o banco vazar, o atacante não terá os tokens reais — apenas hashes irreversíveis. O token original fica apenas no cookie do usuário.
routes/login.php
<?php
// routes/login.php
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../config/jwt.php';
require_once __DIR__ . '/../src/JwtHelper.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: https://meusite.com'); // nunca use * em produção com credenciais
header('Access-Control-Allow-Credentials: true');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit(json_encode(['error' => 'Método não permitido']));
}
$body = json_decode(file_get_contents('php://input'), true);
$email = filter_var($body['email'] ?? '', FILTER_SANITIZE_EMAIL);
$senha = $body['senha'] ?? '';
if (!$email || !$senha) {
http_response_code(400);
exit(json_encode(['error' => 'Email e senha obrigatórios']));
}
// Conexão com PDO (mais seguro que mysqli)
try {
$pdo = new PDO(
"mysql:host={$_ENV['DB_HOST']};dbname={$_ENV['DB_NAME']};charset=utf8mb4",
$_ENV['DB_USER'],
$_ENV['DB_PASS'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
} catch (PDOException $e) {
http_response_code(500);
exit(json_encode(['error' => 'Erro interno']));
}
// Busca o usuário pelo email
$stmt = $pdo->prepare('SELECT id, senha_hash, role FROM usuarios WHERE email = ? AND ativo = 1 LIMIT 1');
$stmt->execute([$email]);
$usuario = $stmt->fetch(PDO::FETCH_ASSOC);
// Verifica credenciais — tempo constante para evitar timing attack
if (!$usuario || !password_verify($senha, $usuario['senha_hash'])) {
// Sempre retorna a mesma mensagem para não revelar se o email existe
http_response_code(401);
exit(json_encode(['error' => 'Credenciais inválidas']));
}
// Gera os tokens
$accessToken = JwtHelper::generateAccessToken($usuario['id'], $usuario['role']);
$refreshToken = bin2hex(random_bytes(32)); // 64 chars, opaco
$tokenHash = hash('sha256', $refreshToken);
$expiresAt = date('Y-m-d H:i:s', time() + JWT_REFRESH_TTL);
// Salva o refresh token no banco
$stmt = $pdo->prepare(
'INSERT INTO refresh_tokens (token, user_id, expires_at) VALUES (?, ?, ?)'
);
$stmt->execute([$tokenHash, $usuario['id'], $expiresAt]);
// Refresh token em cookie httpOnly (não acessível via JavaScript — protege de XSS)
setcookie('refresh_token', $refreshToken, [
'expires' => time() + JWT_REFRESH_TTL,
'path' => '/api/auth', // restrito apenas às rotas de auth
'secure' => true, // apenas HTTPS
'httponly' => true, // JavaScript não acessa
'samesite' => 'Strict',
]);
// Access token no corpo da resposta (o cliente guarda em memória)
http_response_code(200);
echo json_encode([
'access_token' => $accessToken,
'expires_in' => JWT_ACCESS_TTL,
'token_type' => 'Bearer',
]);
routes/refresh.php
<?php
// routes/refresh.php — renova o access token usando o refresh token do cookie
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../config/jwt.php';
require_once __DIR__ . '/../src/JwtHelper.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: https://meusite.com');
header('Access-Control-Allow-Credentials: true');
$refreshToken = $_COOKIE['refresh_token'] ?? null;
if (!$refreshToken) {
http_response_code(401);
exit(json_encode(['error' => 'Refresh token ausente']));
}
$tokenHash = hash('sha256', $refreshToken);
try {
$pdo = new PDO(
"mysql:host={$_ENV['DB_HOST']};dbname={$_ENV['DB_NAME']};charset=utf8mb4",
$_ENV['DB_USER'], $_ENV['DB_PASS'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
} catch (PDOException $e) {
http_response_code(500);
exit(json_encode(['error' => 'Erro interno']));
}
// Busca o token e verifica validade
$stmt = $pdo->prepare(
'SELECT rt.user_id, rt.expires_at, rt.revoked, u.role
FROM refresh_tokens rt
JOIN usuarios u ON u.id = rt.user_id
WHERE rt.token = ? LIMIT 1'
);
$stmt->execute([$tokenHash]);
$record = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$record) {
http_response_code(401);
exit(json_encode(['error' => 'Refresh token inválido']));
}
if ($record['revoked']) {
// Token já foi usado ou revogado — possível roubo de token
// Boa prática: revogar todos os tokens do usuário por segurança
$stmt = $pdo->prepare('UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?');
$stmt->execute([$record['user_id']]);
http_response_code(401);
exit(json_encode(['error' => 'Token revogado. Faça login novamente.']));
}
if (new DateTime() > new DateTime($record['expires_at'])) {
http_response_code(401);
exit(json_encode(['error' => 'Refresh token expirado']));
}
// Revoga o token atual (rotação: cada refresh gera um novo token)
$stmt = $pdo->prepare('UPDATE refresh_tokens SET revoked = 1 WHERE token = ?');
$stmt->execute([$tokenHash]);
// Gera novo par
$novoAccessToken = JwtHelper::generateAccessToken($record['user_id'], $record['role']);
$novoRefreshToken = bin2hex(random_bytes(32));
$novoHash = hash('sha256', $novoRefreshToken);
$novaExpiracao = date('Y-m-d H:i:s', time() + JWT_REFRESH_TTL);
$stmt = $pdo->prepare(
'INSERT INTO refresh_tokens (token, user_id, expires_at) VALUES (?, ?, ?)'
);
$stmt->execute([$novoHash, $record['user_id'], $novaExpiracao]);
setcookie('refresh_token', $novoRefreshToken, [
'expires' => time() + JWT_REFRESH_TTL,
'path' => '/api/auth',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
]);
http_response_code(200);
echo json_encode([
'access_token' => $novoAccessToken,
'expires_in' => JWT_ACCESS_TTL,
'token_type' => 'Bearer',
]);
src/AuthMiddleware.php — proteção de rotas
<?php
// src/AuthMiddleware.php
// Inclua no topo de qualquer rota protegida: require_once 'src/AuthMiddleware.php';
use Firebase\JWT\ExpiredException;
use Firebase\JWT\SignatureInvalidException;
function requireAuth(): object
{
$token = JwtHelper::extractFromHeader();
if (!$token) {
http_response_code(401);
exit(json_encode(['error' => 'Token de acesso ausente']));
}
try {
return JwtHelper::verifyAccessToken($token);
} catch (ExpiredException $e) {
http_response_code(401);
exit(json_encode(['error' => 'Token expirado', 'code' => 'TOKEN_EXPIRED']));
} catch (SignatureInvalidException $e) {
http_response_code(401);
exit(json_encode(['error' => 'Token inválido']));
} catch (Exception $e) {
http_response_code(401);
exit(json_encode(['error' => 'Token inválido']));
}
}
// Verificação de papel (role)
function requireRole(object $payload, string ...$roles): void
{
if (!in_array($payload->role ?? '', $roles, true)) {
http_response_code(403);
exit(json_encode(['error' => 'Sem permissão para este recurso']));
}
}
<?php
// Exemplo de rota protegida: api/usuarios.php
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/config/jwt.php';
require_once __DIR__ . '/src/JwtHelper.php';
require_once __DIR__ . '/src/AuthMiddleware.php';
header('Content-Type: application/json');
// Verifica o token e retorna o payload decodificado
$payload = requireAuth();
// Restringe a rota apenas para admins
// requireRole($payload, 'admin');
// A partir daqui, $payload->sub é o ID do usuário autenticado
echo json_encode([
'usuario_id' => $payload->sub,
'role' => $payload->role,
'dados' => ['...'],
]);
Limpeza periódica de tokens expirados
Hosts compartilhados não têm cron próprio facilmente. Use uma limpeza lazy: a cada N requisições, ou crie um endpoint interno disparado pelo seu deploy:
<?php
// src/cleanup.php — chame periodicamente (cron do painel do host, ou via deploy)
// Proteja com uma chave interna para não ser acessível publicamente
$chaveInterna = $_SERVER['HTTP_X_CLEANUP_KEY'] ?? '';
if ($chaveInterna !== ($_ENV['CLEANUP_KEY'] ?? '')) {
http_response_code(403);
exit;
}
$stmt = $pdo->prepare(
'DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked = 1 AND created_at < DATE_SUB(NOW(), INTERVAL 30 DAY))'
);
$stmt->execute();
echo json_encode(['deleted' => $stmt->rowCount()]);
Cenário B: JavaScript puro (React, Vue, Angular, vanilla)
O frontend não é um servidor — ele não emite tokens, apenas os consome. Mas onde e como armazenar o token faz toda a diferença de segurança.
Onde guardar o access token
Recomendação prática: o access token fica em memória (variável JS), e o refresh token fica em cookie httpOnly (enviado automaticamente pelo browser, inacessível ao JavaScript). Ao recarregar a página, o access token some — mas o refresh token no cookie renova tudo automaticamente.
Módulo de autenticação (funciona em qualquer framework)
// src/auth/authService.js
// Framework-agnóstico: funciona com React, Vue, Angular ou vanilla JS
const API_BASE = 'https://meusite.com/api';
// Access token fica apenas em memória — não persiste entre recarregamentos,
// mas isso é intencional: o refresh token no cookie cuida da renovação.
let _accessToken = null;
let _refreshPromise = null; // evita múltiplas chamadas de refresh simultâneas
/**
* Realiza login e armazena o access token em memória.
* O refresh token é salvo automaticamente em cookie httpOnly pelo servidor.
*/
export async function login(email, senha) {
const response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // necessário para enviar/receber cookies
body: JSON.stringify({ email, senha }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Falha no login');
}
const data = await response.json();
_accessToken = data.access_token;
// Agenda renovação automática antes da expiração
scheduleRefresh(data.expires_in);
return data;
}
/**
* Renova o access token usando o refresh token do cookie.
* Chamada automática antes do token expirar, ou quando uma requisição
* retorna 401 com code TOKEN_EXPIRED.
*
* _refreshPromise garante que chamadas simultâneas esperem pelo mesmo refresh,
* em vez de disparar múltiplas requisições de renovação.
*/
export async function refreshAccessToken() {
if (_refreshPromise) return _refreshPromise;
_refreshPromise = fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
credentials: 'include', // envia o cookie refresh_token
})
.then(async (response) => {
if (!response.ok) {
_accessToken = null;
throw new Error('Sessão expirada. Faça login novamente.');
}
const data = await response.json();
_accessToken = data.access_token;
scheduleRefresh(data.expires_in);
return data.access_token;
})
.finally(() => {
_refreshPromise = null;
});
return _refreshPromise;
}
/**
* Faz logout: remove o token da memória e notifica o servidor
* para revogar o refresh token no banco.
*/
export async function logout() {
_accessToken = null;
await fetch(`${API_BASE}/auth/logout`, {
method: 'POST',
credentials: 'include',
}).catch(() => {}); // falha silenciosa — logout local já está feito
}
/**
* Wrapper para fetch autenticado com renovação automática.
* Use no lugar de fetch() em qualquer requisição autenticada.
*/
export async function authFetch(url, options = {}) {
// Tenta renovar se não tiver token em memória (ex: após recarregar a página)
if (!_accessToken) {
await refreshAccessToken();
}
const makeRequest = async (token) =>
fetch(url, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
Authorization: `Bearer ${token}`,
},
});
let response = await makeRequest(_accessToken);
// Se expirou durante a requisição, renova e tenta uma vez mais
if (response.status === 401) {
const body = await response.clone().json().catch(() => ({}));
if (body.code === 'TOKEN_EXPIRED') {
const newToken = await refreshAccessToken();
response = await makeRequest(newToken);
}
}
return response;
}
// Agenda renovação automática 60 segundos antes de expirar
let _refreshTimer = null;
function scheduleRefresh(expiresInSeconds) {
if (_refreshTimer) clearTimeout(_refreshTimer);
const delay = Math.max((expiresInSeconds - 60) * 1000, 0);
_refreshTimer = setTimeout(refreshAccessToken, delay);
}
// Verifica se há sessão ativa (para restaurar estado após reload)
export function isAuthenticated() {
return _accessToken !== null;
}
Integração com React
// src/auth/AuthContext.jsx
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { login, logout, refreshAccessToken, isAuthenticated } from './authService';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true); // verifica sessão ao carregar
// Ao montar, tenta restaurar sessão via refresh token no cookie
useEffect(() => {
refreshAccessToken()
.then(() => {
// Token renovado — considera o usuário autenticado
// Em produção, decodifique o payload para pegar dados do usuário
setUser({ authenticated: true });
})
.catch(() => {
setUser(null);
})
.finally(() => setLoading(false));
}, []);
const handleLogin = useCallback(async (email, senha) => {
await login(email, senha);
setUser({ authenticated: true });
}, []);
const handleLogout = useCallback(async () => {
await logout();
setUser(null);
}, []);
if (loading) return <div>Verificando sessão...</div>;
return (
<AuthContext.Provider value={{ user, login: handleLogin, logout: handleLogout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth deve estar dentro de AuthProvider');
return ctx;
}
// Componente de login
import { useState } from 'react';
import { useAuth } from '../auth/AuthContext';
export function LoginForm() {
const { login } = useAuth();
const [erro, setErro] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setLoading(true);
setErro('');
const form = new FormData(e.target);
try {
await login(form.get('email'), form.get('senha'));
// redirecione para a página principal
} catch (err) {
setErro(err.message);
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required placeholder="Email" />
<input name="senha" type="password" required placeholder="Senha" />
{erro && <p style={{ color: 'red' }}>{erro}</p>}
<button type="submit" disabled={loading}>
{loading ? 'Entrando...' : 'Entrar'}
</button>
</form>
);
}
// Rota protegida
import { useAuth } from '../auth/AuthContext';
import { Navigate } from 'react-router-dom';
export function RotaProtegida({ children }) {
const { user } = useAuth();
return user ? children : <Navigate to="/login" replace />;
}
Integração com Vue 3
// src/auth/useAuth.js — Composable Vue 3
import { ref, readonly } from 'vue';
import { login, logout, refreshAccessToken } from './authService';
const user = ref(null);
const loading = ref(true);
// Inicializa tentando restaurar sessão
refreshAccessToken()
.then(() => { user.value = { authenticated: true }; })
.catch(() => { user.value = null; })
.finally(() => { loading.value = false; });
export function useAuth() {
async function handleLogin(email, senha) {
await login(email, senha);
user.value = { authenticated: true };
}
async function handleLogout() {
await logout();
user.value = null;
}
return {
user: readonly(user),
loading: readonly(loading),
login: handleLogin,
logout: handleLogout,
};
}
// src/router/index.js — guarda de rota Vue Router
import { createRouter } from 'vue-router';
import { useAuth } from '../auth/useAuth';
router.beforeEach((to) => {
const { user, loading } = useAuth();
if (to.meta.requiresAuth && !user.value) {
return { name: 'login', query: { redirect: to.fullPath } };
}
});
Integração com Angular
// src/app/auth/auth.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, from } from 'rxjs';
import { tap } from 'rxjs/operators';
import { login, logout, refreshAccessToken } from './authService';
@Injectable({ providedIn: 'root' })
export class AuthService {
private userSubject = new BehaviorSubject<any>(null);
user$ = this.userSubject.asObservable();
async init() {
try {
await refreshAccessToken();
this.userSubject.next({ authenticated: true });
} catch {
this.userSubject.next(null);
}
}
async login(email: string, senha: string) {
await login(email, senha);
this.userSubject.next({ authenticated: true });
}
async logout() {
await logout();
this.userSubject.next(null);
}
isAuthenticated(): boolean {
return this.userSubject.value !== null;
}
}
// src/app/auth/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isAuthenticated()) return true;
return router.createUrlTree(['/login']);
};
O que todos os cenários têm em comum
Independente do stack, três regras nunca mudam:
1. Access token de vida curta + refresh token de vida longa
Token de 7 dias em localStorage é a combinação mais perigosa possível. Token de 15 minutos em memória, com renovação automática via cookie httpOnly, é a arquitetura correta.
2. Nunca armazene o access token em localStorage
localStorage persiste entre abas, sessões e recarregamentos. Qualquer script injetado via XSS tem acesso imediato. Memória RAM é limpa ao fechar a aba.
3. O payload do JWT é público
atob(token.split('.')[1]) decodifica o payload em qualquer console de browser. Nunca coloque CPF, email completo, número de cartão ou qualquer dado que não deva ser exposto no payload.
Referências
- RFC 7519 — JSON Web Token
- firebase/php-jwt — GitHub
- OWASP — JWT Security Cheat Sheet
- OWASP — Authentication Cheat Sheet
- RFC 6749 — The OAuth 2.0 Authorization Framework
Tags: jwt, oauth2, php, javascript, react, vue, angular, segurança, autenticação, host-compartilhado