O que o OAuth 2.0 é, e o que não é
O OAuth 2.0 é um framework de autorização. Ele permite que um usuário conceda a um aplicativo de terceiros acesso limitado aos seus recursos sem a necessidade de compartilhar sua senha. O resultado de um fluxo bem-sucedido é um access token, e esse token descreve o que o cliente pode fazer, e não necessariamente quem o usuário é.
Essa distinção confunde as pessoas constantemente. O “Sign in with Google” é o OAuth 2.0 somado ao OpenID Connect. A parte do OAuth obtém acesso às APIs do Google; a parte do OpenID Connect retorna um ID token que efetivamente identifica o usuário. Se você implementar apenas o OAuth e tratar o access token como prova de identidade, você terá construído um acesso delegado e o chamado de autenticação, o que é um erro de categoria com consequências de segurança.
O protocolo foi projetado para um problema específico: permitir que um site de impressão de fotos leia suas fotos de um provedor de armazenamento sem nunca ver a sua senha do armazenamento. Mantenha essa origem em mente e as escolhas de design farão sentido.
OpenID Connect: identidade sobreposta
O OpenID Connect é uma camada fina de identidade sobre o OAuth 2.0. Ele adiciona o escopo openid, um endpoint userinfo padronizado e, mais importante, um ID token, um JWT cujas claims descrevem o evento de autenticação.
{
"iss": "https://accounts.example.com",
"sub": "110169484474386276334",
"aud": "web-app",
"exp": 1760000000,
"iat": 1759999100,
"email": "[email protected]",
"email_verified": true,
"nonce": "n-0S6_WzA2Mj"
}
O ID token é destinado ao cliente, não à API. Nunca o envie para um servidor de recursos como se fosse um access token. Verifique sua assinatura, iss, aud, exp e nonce antes de confiar nele, e utilize o sub, e não o e-mail, como o identificador estável do usuário. Endereços de e-mail mudam e são reatribuídos; o subject é estável durante toda a vida da conta.
As quatro funções
O OAuth define quatro participantes, e ser preciso sobre eles torna o restante do protocolo óbvio.
- Resource owner — o usuário que possui os dados e concede o acesso.
- Client — a aplicação que solicita o acesso, por exemplo, seu web app.
- Authorization server — emite tokens após autenticar o usuário e obter o consentimento.
- Resource server — a API que aceita o access token e retorna os dados.
Seu web app é o client. O Google é o authorization server. As APIs do Google são o resource server. O usuário é o resource owner. Um único provedor geralmente desempenha ambos os papéis de servidor, e é por isso que a distinção pode parecer acadêmica até que você construa a sua própria.
Tipos de grant
Um grant type é a “receita” que um cliente utiliza para obter um token. Quatro deles são relevantes hoje em dia.
- Authorization Code + PKCE — o padrão para web, mobile e single-page apps. O usuário se autentica no servidor de autorização e o cliente troca um código de curta duração por tokens.
- Client Credentials — machine-to-machine. Nenhum usuário está envolvido; o cliente se autentica com suas próprias credenciais.
- Device Code — para dispositivos com entrada limitada, como televisões e ferramentas de CLI.
- Refresh Token — não é uma forma de fazer login, mas a maneira padrão de renovar um access token sem a necessidade de outro redirecionamento.
Dois grants estão efetivamente obsoletos. O fluxo implicit retornava tokens diretamente no fragmento da URL, expondo-os ao histórico e referrers. O grant password solicitava que o cliente manipulasse a senha do usuário, o que anula todo o propósito do OAuth. O OAuth 2.1 remove ambos, e nenhum sistema novo deve utilizá-los.
Authorization Code + PKCE
Este é o fluxo que você deve aprender. O cliente redireciona o usuário para o servidor de autorização com um challenge hasheado, o usuário consente, o servidor redireciona de volta com um código de uso único e o cliente troca esse código, junto com o verifier original, por tokens.
O código é inútil sem o verifier, portanto, um atacante que intercepte o redirecionamento não conseguirá completar a troca. É isso que torna o PKCE seguro mesmo para clientes públicos, como apps mobile e single-page apps, que não conseguem manter um secret de forma segura.
O fluxo possui dois redirecionamentos de navegador e uma requisição de back-channel. Os redirecionamentos são visíveis e podem ser manipulados; a troca é um POST direto de servidor para servidor que um atacante não consegue observar. Manter o material secreto no back channel é a base de todo o design.
A URI de redirecionamento e o state
A redirect URI é para onde o servidor de autorização envia o usuário de volta. Ela deve ser registrada e validada com precisão exata. Validações permissivas são a causa de vulnerabilidades de open-redirect que vazam códigos de autorização para hosts controlados por atacantes. Nunca aceite uma redirect URI vinda de um query parameter e não permita subdomínios com wildcard.
O parâmetro state é um valor opaco que o cliente gera e verifica quando o callback retorna. Ele protege o endpoint de callback contra CSRF. Um atacante que engane a vítima para completar um fluxo com o código do atacante falhará na verificação do state.
const state = randomBytes(16).toString("base64url");
session.oauthState = state;
// later, in the callback:
if (query.state !== session.oauthState) {
throw new Error("state_mismatch");
}
Para OIDC, adicione um nonce e verifique-o dentro do ID token. O state protege o callback do cliente contra CSRF; o nonce vincula o ID token a esta requisição específica e bloqueia ataques de replay.
PKCE em detalhes
O PKCE (Proof Key for Code Exchange) é definido pela RFC 7636 e agora é obrigatório para clientes públicos. Ele consiste em três valores: o cliente gera um code_verifier aleatório, deriva um code_challenge como seu hash SHA-256, envia o challenge na requisição de autorização e envia o verifier na requisição do token. O servidor então gera o hash do verifier e faz a comparação.
import { randomBytes, createHash } from "node:crypto";
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
Use sempre o método S256. O método plain existe apenas por questões de compatibilidade e anula o propósito do PKCE, já que um atacante que veja o challenge verá também o verifier. Armazene o verifier na sessão do usuário para que o callback possa encontrá-lo, e delete-o após um único uso.
Construindo a requisição de autorização
A requisição de autorização é um redirecionamento do navegador, portanto, trata-se de um GET com parâmetros de query. O usuário verá a tela de consentimento do provedor, e não a sua aplicação.
export function buildAuthorizeUrl(state: string, challenge: string) {
const url = new URL("https://auth.example.com/authorize");
url.search = new URLSearchParams({
response_type: "code",
client_id: "web-app",
redirect_uri: "https://app.example.com/callback",
scope: "openid email profile offline_access",
state,
code_challenge: challenge,
code_challenge_method: "S256",
}).toString();
return url.toString();
}
response_type=code seleciona o fluxo de código de autorização (authorization code flow). O escopo openid transforma a requisição em uma requisição OIDC. offline_access é a convenção comum para solicitar um refresh token, e alguns provedores exigem um prompt de consentimento extra para liberá-lo.
Troca de tokens
O callback entrega code e state. Após a verificação do state, o cliente faz um POST do code para o endpoint de token. Esta é uma chamada de back-channel feita pelo seu servidor, e não um redirecionamento do navegador.
export async function exchangeCode(code: string, verifier: string) {
const res = await fetch("https://auth.example.com/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: "https://app.example.com/callback",
client_id: "web-app",
code_verifier: verifier,
}),
});
if (!res.ok) throw new Error("token_exchange_failed");
return res.json();
}
A resposta contém access_token, token_type, expires_in, geralmente refresh_token e, para OIDC, um id_token. Um cliente confidencial, aquele que consegue armazenar um secret, também se autentica nesta requisição com client_secret ou, preferencialmente, uma asserção de chave privada. Um cliente público depende exclusivamente do PKCE.
Tokens de acesso, refresh e ID
Esses três tokens possuem funções diferentes, e confundi-los causa bugs sutis.
- Access token — apresentado ao servidor de recursos. Frequentemente é um JWT, mas a especificação exige apenas que ele seja opaco para o cliente. Pode ser apenas uma string aleatória que o servidor consulta.
- Refresh token — apresentado apenas ao servidor de autorização para obter um novo access token. Possui longa duração e é altamente sensível.
- ID token — um JWT do OIDC que informa ao cliente quem acabou de fazer login. Ele nunca é enviado para uma API.
Trate o access token como uma credencial de portador (bearer credential): qualquer pessoa que o possua pode utilizá-lo. Mantenha o tempo de vida curto, solicite os scopes mais restritos possíveis e deixe que o refresh token gerencie o relacionamento de longa duração. O formato do token em si é abordado no guia de JWT.
Escopos e consentimento
Os escopos expressam o que o cliente está solicitando. O servidor de autorização os apresenta ao usuário em uma tela de consentimento e codifica o subconjunto concedido no access token.
Solicite o mínimo necessário. Um app de calendário que pede acesso total à caixa de entrada assustará os usuários e aumentará o raio de impacto em caso de uma violação. Os provedores também publicam escopos reservados: openid é obrigatório para OIDC, e offline_access geralmente controla os refresh tokens.
Escopos não são roles. Um escopo descreve uma capacidade que o cliente solicitou para esta concessão; uma role descreve quem o usuário é dentro do seu sistema. Faça o mapeamento entre eles no seu servidor e nunca assuma que os escopos de um provedor dizem algo sobre o seu próprio modelo de autorização. Para esse lado do problema, veja RBAC.
Chamando o servidor de recursos
Com um access token em mãos, as chamadas para a API o transportam no header Authorization utilizando o esquema Bearer.
const res = await fetch("https://api.example.com/me", {
headers: { authorization: `Bearer ${accessToken}` },
});
O servidor de recursos valida o token, seja verificando um JWT localmente ou através de introspecção, checa o scope e retorna os dados. Ele nunca vê o refresh token ou o ID token, e deve rejeitá-los caso os receba.
Introspecção e revogação
Nem todo access token é um JWT. Quando ele é opaco, o resource server pergunta ao authorization server se ele ainda é válido usando a introspecção de token (token introspection), definida pela RFC 7662.
POST /introspect HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <client credentials>
token=2YotnFZFEjr1zCsicMWpAA
A introspecção retorna active, além do scope, subject e expiração. Ela é autoritativa, mas custa uma chamada de rede, portanto, faça o cache do resultado por alguns segundos.
A revogação (revocation), definida pela RFC 7009, permite que um cliente informe ao authorization server para invalidar um token, geralmente no logout.
await fetch("https://auth.example.com/revoke", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ token: refreshToken, client_id: "web-app" }),
});
Revogue refresh tokens agressivamente no logout e lembre-se de que revogar um refresh token nem sempre invalida access tokens já emitidos. Tempos de vida curtos para o access token são o que fazem a revogação parecer imediata.
Machine-to-machine: client credentials
Quando não há um usuário envolvido, o cliente atua por conta própria. Ele se autentica no endpoint de token e recebe um access token com o escopo de suas próprias permissões.
const res = await fetch("https://auth.example.com/token", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`,
},
body: new URLSearchParams({
grant_type: "client_credentials",
scope: "reports:read",
}),
});
Não há tela de consentimento nem refresh token; o serviço simplesmente solicita um novo token quando o anterior expira. Prefira a autenticação de cliente via private-key JWT em vez de um shared secret quando o provedor oferecer suporte, e rotacione os secrets periodicamente. Este é o mesmo nicho ocupado por API keys de longa duração, porém com expiração e escopo padronizados, conforme abordado em API Keys.
Quando não usar OAuth
O OAuth serve para delegar acesso a terceiros. Se o seu próprio web app está autenticando seus próprios usuários contra seu próprio backend, você não precisa de OAuth. Um session cookie, ou um JWT de primeira parte emitido por você mesmo, é mais simples e fácil de proteger. Colocar um servidor de autorização entre seu formulário de login e seu banco de dados traz complexidade, não segurança.
Recorra ao OAuth quando você integrar um provedor externo, atuar como um provedor para clientes de terceiros ou precisar de um padrão para acesso machine-to-machine. Caso contrário, comece com Session Auth e adicione OAuth apenas quando surgir uma necessidade real de delegação. Como cada fluxo aqui funciona através de redirecionamentos e headers, o guia de HTTP é um complemento útil.
Armadilhas comuns
- Implicit flow — tokens no fragmento da URL vazam através do histórico, logs e referrers. Use Authorization Code com PKCE.
- Tokens no localStorage — um bug de XSS torna-se um roubo de conta (account takeover). Mantenha os tokens em cookies httpOnly ou sessões no server-side.
- Open redirects — a validação permissiva da redirect URI permite que um atacante roube códigos. Faça a correspondência exata com a URI registrada.
- Ignorar o state — sem ele, o callback fica vulnerável a CSRF.
- Scopes excessivamente amplos — solicitar tudo torna o consentimento irrelevante e agrava as consequências de uma violação.
- Access tokens de longa duração — eles não podem ser revogados rapidamente. Mantenha-os curtos e faça a rotação de refresh tokens.
- Usar o ID token como credencial de API — ele serve para o cliente, não para o resource server.
Escolhendo o fluxo correto
A maioria das decisões de integração se resume a duas perguntas: existe um usuário e o cliente consegue manter um segredo?
- Há um usuário presente, o cliente é público (SPA, app mobile, desktop): Authorization Code com PKCE.
- Há um usuário presente, o cliente é confidencial (web app com renderização no servidor): Authorization Code com PKCE mais autenticação de cliente.
- Sem usuário, o cliente é um serviço (cron job, microserviço): Client Credentials.
- O dispositivo não possui navegador ou teclado (TV, CLI): Device Code.
Todo o resto é legado. Se um provedor documenta apenas o fluxo implicit, encare isso como um sinal de alerta e verifique se o PKCE está disponível.
O padrão backend-for-frontend
O lugar mais seguro para armazenar tokens é em um servidor que você controla. O padrão backend-for-frontend coloca um servidor leve entre o navegador e o servidor de autorização: o navegador recebe um cookie de sessão, e o servidor armazena os access e refresh tokens.
app.get("/callback", async (req, res) => {
if (req.query.state !== req.session.oauthState) {
return res.status(400).json({ error: "state_mismatch" });
}
const tokens = await exchangeCode(
String(req.query.code),
req.session.codeVerifier,
);
req.session.tokens = tokens;
delete req.session.oauthState;
delete req.session.codeVerifier;
res.redirect("/dashboard");
});
O navegador nunca vê um token, portanto, um XSS não consegue roubá-lo, e o servidor pode realizar o refresh silenciosamente. A contrapartida é um salto extra e a necessidade de manter um servidor rodando, o que geralmente sai mais barato do que o incidente que você evita.
Clientes nativos e mobile
Apps nativos não conseguem manter um client secret, portanto, o PKCE não é opcional. Eles também enfrentam um problema de redirecionamento: um scheme customizado, como myapp://callback, pode ser reivindicado por um app malicioso. A solução moderna é utilizar uma aba do navegador do sistema, seja através da API de sessão de autenticação da plataforma ou de um navegador incorporado que compartilhe cookies com o sistema.
Nunca utilize um WebView incorporado para OAuth. O app poderá ler a senha do usuário, o que quebra a barreira de confiança que todo o protocolo visa proteger. Abra o navegador do sistema, receba o callback e armazene os tokens no armazenamento seguro da plataforma.
Executando seu próprio servidor de autorização
Você não precisa construir um do zero. Servidores consolidados como Keycloak, Ory Hydra e provedores de identidade na nuvem já implementam o protocolo, telas de consentimento, gerenciamento de chaves e armazenamento de tokens para você. Construir o seu próprio é um projeto de vários meses cujos modos de falha são todos críticos para a segurança.
Se você optar por executar o seu próprio, as responsabilidades mínimas são: correspondência exata de redirect URI, imposição de PKCE, tempos de vida curtos para access-tokens, rotação de refresh-tokens com detecção de reuso, um endpoint JWKS e revogação. Esquecer qualquer um desses pontos significa que você entregou uma vulnerabilidade, não uma funcionalidade.
Testando uma integração OAuth
Testes end-to-end de OAuth são lentos e frágeis porque dependem de um provedor real e de um navegador real. Em vez disso, teste em camadas.
- Faça testes unitários da construção de URLs, derivação de PKCE e comparação de state como funções puras.
- Faça testes de integração da troca de tokens contra um servidor de autorização mock que retorne tokens pré-definidos.
- Mantenha um único smoke test contra o provedor real, marcado para que não seja executado em cada commit.
test("builds an authorize URL with PKCE", () => {
const url = new URL(buildAuthorizeUrl("state-1", "challenge-1"));
expect(url.searchParams.get("response_type")).toBe("code");
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
expect(url.searchParams.get("state")).toBe("state-1");
});
O callback é onde os bugs se escondem, portanto, teste os caminhos de falha explicitamente: um state incompatível, um código reutilizado e um token expirado devem produzir erros claros, em vez de uma sessão parcialmente autenticada.
Logout e single sign-on
Fazer logout da sua aplicação não é o mesmo que fazer logout do provedor. Um logout completo realiza três ações: destrói a sessão local, revoga o refresh token no servidor de autorização e, no caso de single sign-on, encerra a sessão do provedor para que o próximo login não a reutilize silenciosamente.
app.post("/logout", async (req, res) => {
if (req.session.tokens?.refresh_token) {
await revoke(req.session.tokens.refresh_token);
}
req.session.destroy(() => res.redirect("/"));
});
O single sign-on funciona com a mesma mecânica: assim que um usuário possui uma sessão no provedor, os clientes subsequentes recebem um código sem a necessidade de inserir a senha novamente. Isso é conveniente, e é também por isso que o logout em computadores compartilhados é mais importante do que as pessoas imaginam.
Rotação de refresh token e detecção de reuso
O OAuth não exige que os refresh tokens sejam de uso único, mas as implementações mais robustas utilizam a rotação. Cada atualização retorna um novo refresh token e invalida o anterior. Se um token antigo for apresentado novamente, o servidor identifica que ele foi roubado e revoga toda a família de tokens.
export async function refresh(grant: { refresh_token: string; client_id: string }) {
const stored = await db.refreshToken.findByHash(hash(grant.refresh_token));
if (!stored || stored.revoked) {
if (stored) await revokeFamily(stored.familyId);
throw new Error("invalid_grant");
}
await db.refreshToken.revoke(stored.id);
return issueTokens(stored.userId, stored.familyId);
}
A detecção de reuso é a grande vantagem: um refresh token roubado torna-se um gatilho de alerta em vez de uma porta dos fundos permanente. Combine isso com uma expiração deslizante (sliding expiry) para que um usuário ativo permaneça conectado, enquanto um token abandonado eventualmente expire.
Melhores práticas
- Use Authorization Code com PKCE para todo cliente voltado ao usuário, incluindo SPAs e mobile.
- Use Client Credentials para comunicação machine-to-machine, com autenticação via private-key sempre que possível.
- Registre redirect URIs exatas e rejeite qualquer URI que não coincida caractere por caractere.
- Sempre envie e verifique
state; adicione e verifique umnoncepara OIDC. - Solicite os scopes mínimos e trate a tela de consentimento como algo relevante.
- Mantenha access tokens com vida curta, rotacione refresh tokens e revogue-os no logout.
- Verifique a assinatura do ID token,
iss,aud,expenonceantes de confiar nele. - Mantenha client secrets e private keys em um secret manager, nunca no frontend.
- Faça cache de introspection por curtos períodos e dependa de lifetimes curtas para revogações tempestivas.
Erros comuns
- Tratar OAuth como autenticação sem o OpenID Connect.
- Implementar o implicit flow apenas por ter um redirecionamento a menos.
- Armazenar access tokens no localStorage e enviá-los para qualquer origin.
- Colocar o client secret em uma single-page app onde qualquer pessoa pode lê-lo.
- Aceitar qualquer redirect URI, ou uma fornecida via query parameter.
- Esquecer de validar
stateno callback. - Solicitar todos os scopes possíveis e nunca revisar a lista.
- Assumir que revogar um refresh token invalida instantaneamente os access tokens.
- Usar o ID token como um bearer token para sua API.
Próximos passos
O OAuth é a forma como os tokens são obtidos; o guia de JWT explica como eles são construídos e verificados. Se o seu app é first-party e você precisa de revogação imediata, Session Auth é o ponto de partida mais simples. Para clientes de máquina que não envolvem um usuário, API Keys aborda a alternativa de longa duração. E como cada um desses fluxos trafega pela rede, vale a pena dar uma revisada em HTTP.