Back

OAuth 2.1 chegou: O que mudou, o que foi deprecado e como migrar sua app

Se você shippou uma single-page application antes de 2024, sua implementação OAuth provavelmente é insegura. Não "teoricamente vulnerável" — realmente explorável.

O Implicit Grant flow que todo tutorial de React te ensinou a usar? Removido no OAuth 2.1. O flow de Resource Owner Password Credentials (ROPC) que seu app mobile usa? Também removido. Bearer tokens em query strings de URL? Proibido.

OAuth 2.1 não é um bump de versão menor. São dez anos de lições de segurança codificadas em spec, e quebra código real de produção. As libs que você usa já estão shippando defaults do OAuth 2.1. Os Identity Providers tão deprecando endpoints legados. Se você ainda não migrou, tá vivendo de emprestado.

Este guia cobre cada breaking change no OAuth 2.1, explica por que cada decisão foi tomada (pra você saber que não é burocracia arbitrária) e fornece código TypeScript pronto pra produção pra migrar suas implementações existentes.

O que é o OAuth 2.1, exatamente?

OAuth 2.1 não é um protocolo novo. É uma consolidação do OAuth 2.0 (RFC 6749) mais cada RFC de best practices de segurança publicado desde 2012. Pense nisso como o OAuth 2.0 com catorze anos de errata, advisories de segurança e recomendações de "você deveria estar fazendo isso" incorporadas diretamente na spec core.

Os RFCs principais que ele absorve:

RFCO que cobreImpacto no OAuth 2.1
RFC 7636PKCE (Proof Key for Code Exchange)Agora obrigatório pra todos os clientes
RFC 7009Revogação de tokensIntegrado como feature core
RFC 8252Best practices pra apps nativasRedirects loopback padronizados
RFC 9207Identificação de Issuer do Authorization ServerVerificação do issuer obrigatória
RFC 9126Pushed Authorization Requests (PAR)Recomendado pra fluxos de alta segurança
RFC 9449DPoP (Demonstration of Proof-of-Possession)Recomendado sobre bearer tokens

O efeito prático: uma spec pra ler em vez de seis. Mas o custo de migração é real, porque o OAuth 2.1 remove fluxos que milhões de aplicações ainda usam.

As quatro breaking changes

1. Implicit Grant morreu

O que foi removido: O fluxo response_type=token inteiro.

No OAuth 2.0, o Implicit Grant foi projetado pra apps baseadas em browser que não conseguiam armazenar um client secret de forma segura. O authorization server retornava um access token direto no URL fragment (#access_token=...). Era simples. Também era um desastre de segurança.

Por que é inseguro:

// A cadeia de vulnerabilidades do Implicit Flow

1. Usuário clica em "Login com Google"
2. Redirect pra: https://auth.example.com/authorize?
     response_type=token&
     client_id=my-spa&
     redirect_uri=https://app.example.com/callback

3. Depois de autenticar, redirect de volta:
   https://app.example.com/callback#access_token=eyJhbGciOi...

// 🚨 Problema 1: Token no URL fragment
// - Visível no histórico do browser
// - Logado por qualquer proxy/CDN entre o usuário e o server
// - Acessível pra qualquer JavaScript na página (XSS = game over)

// 🚨 Problema 2: Sem como verificar quem recebe o token
// - Um atacante pode interceptar o redirect e roubar o token
// - Sem PKCE, sem code exchange, sem etapa de verificação

// 🚨 Problema 3: Sem refresh tokens
// - Tokens de curta duração significam reautenticação constante
// - Usuários ficam frustrados, devs estendem o lifetime
// - Tokens de longa duração em URL fragments = segurança pior

A substituição no OAuth 2.1: Authorization Code flow com PKCE. Toda SPA, todo app mobile, todo cliente que usava Implicit precisa migrar.

// ❌ Antes: Implicit Grant (REMOVIDO no OAuth 2.1) const authUrl = new URL('https://auth.example.com/authorize'); authUrl.searchParams.set('response_type', 'token'); // ← Proibido authUrl.searchParams.set('client_id', CLIENT_ID); authUrl.searchParams.set('redirect_uri', REDIRECT_URI); window.location.href = authUrl.toString(); // ✅ Agora: Authorization Code + PKCE const codeVerifier = generateCodeVerifier(); const codeChallenge = await generateCodeChallenge(codeVerifier); const authUrl = new URL('https://auth.example.com/authorize'); authUrl.searchParams.set('response_type', 'code'); // ← Code, não token authUrl.searchParams.set('client_id', CLIENT_ID); authUrl.searchParams.set('redirect_uri', REDIRECT_URI); authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); window.location.href = authUrl.toString();

2. PKCE é obrigatório pra TODOS os clientes

O que mudou: PKCE era opcional no OAuth 2.0, recomendado só pra clientes públicos (SPAs, apps mobile). No OAuth 2.1, é obrigatório pra todo tipo de cliente — incluindo aplicações server-side confidenciais que já têm um client secret.

Por que até clientes confidenciais precisam de PKCE:

Mesmo com um client secret, o authorization code pode ser interceptado durante o redirect. O PKCE previne ataques de injeção de authorization code onde um atacante substitui o próprio code na sessão da vítima. O client secret protege o token endpoint; o PKCE protege o fluxo de autorização.

Como o PKCE funciona:

import { createHash, randomBytes } from 'crypto'; // Passo 1: Gerar um code verifier criptograficamente aleatório function generateCodeVerifier(): string { // 32 bytes = 43 caracteres em base64url return randomBytes(32) .toString('base64url'); } // Passo 2: Criar o code challenge a partir do verifier async function generateCodeChallenge(verifier: string): Promise<string> { // Hash SHA-256, depois base64url encode const hash = createHash('sha256') .update(verifier) .digest(); return Buffer.from(hash).toString('base64url'); } // Passo 3: Incluir na authorization request const verifier = generateCodeVerifier(); const challenge = await generateCodeChallenge(verifier); // Enviar challenge pro authorization server // Guardar verifier de forma segura (sessionStorage, não localStorage) sessionStorage.setItem('pkce_verifier', verifier); // Passo 4: Incluir verifier no token exchange async function exchangeCode(code: string): Promise<TokenResponse> { const verifier = sessionStorage.getItem('pkce_verifier'); const response = 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: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier!, // ← Prova de que nós fizemos o request original }), }); return response.json(); }

O fluxo de verificação:

Client                          Auth Server
  |                                  |
  |-- code_challenge=SHA256(v) ----> |  Authorization Request
  |                                  |  (guarda challenge)
  |<-------- code=abc123 ----------- |  Authorization Response
  |                                  |
  |-- code=abc123 -----------------> |  Token Request
  |   code_verifier=v                |  (calcula SHA256(v),
  |                                  |   compara com challenge guardado)
  |<-------- access_token ---------- |  Token Response
  |                                  |

// Se um atacante interceptar o code, não consegue trocar
// porque não tem o code_verifier que corresponde
// ao code_challenge enviado no request original.

3. ROPC (Resource Owner Password Credentials) morreu

O que foi removido: O fluxo grant_type=password.

ROPC permitia que aplicações coletassem o username e password do usuário diretamente e trocassem por tokens. Foi projetado como um "caminho de migração" pra apps legadas que não conseguiam redirecionar pra um authorization server. Na prática, virou uma muleta que eliminou todos os benefícios de segurança do OAuth.

Por que foi removido:

// ❌ ROPC: Seu app lida com credenciais brutas const response = await fetch('https://auth.example.com/token', { method: 'POST', body: new URLSearchParams({ grant_type: 'password', // ← Removido no OAuth 2.1 username: '[email protected]', // ← O app vê a senha password: 'hunter2', // ← Risco de phishing, credential stuffing client_id: CLIENT_ID, }), }); // 🚨 Problemas: // 1. O app tem a senha bruta do usuário — viola o propósito inteiro do OAuth // 2. Sem suporte MFA — não dá pra fazer 2FA com password grant // 3. Sem tela de consentimento — o usuário não controla permissões // 4. Treina usuários a digitar senhas em apps de terceiros // 5. Se seu app for comprometido, todas as senhas ficam expostas

A substituição no OAuth 2.1 depende do seu caso de uso:

CenárioFluxo anteriorFluxo novo
Login de usuário (web/mobile)ROPCAuthorization Code + PKCE
Auth machine-to-machineROPC (abusado)Client Credentials
Auth de ferramenta CLIROPCDevice Authorization (RFC 8628)
Migração de sistema legadoROPCToken Exchange (RFC 8693)

Device Authorization flow pra CLIs:

// ✅ Device Authorization Flow (substitui ROPC pra ferramentas CLI) async function deviceLogin(): Promise<void> { // Passo 1: Pedir um device code const deviceResponse = await fetch('https://auth.example.com/device', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: CLI_CLIENT_ID, scope: 'read write', }), }); const { device_code, user_code, verification_uri, interval } = await deviceResponse.json(); // Passo 2: Mostrar código pro usuário console.log(`Abra ${verification_uri} e digite o código: ${user_code}`); // Passo 3: Fazer polling até completar while (true) { await sleep(interval * 1000); const tokenResponse = await fetch('https://auth.example.com/token', { method: 'POST', body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code, client_id: CLI_CLIENT_ID, }), }); const result = await tokenResponse.json(); if (result.access_token) { saveTokens(result); console.log('Autenticação bem-sucedida!'); return; } if (result.error === 'expired_token') { throw new Error('Login expirou. Tente de novo.'); } // 'authorization_pending' ou 'slow_down' → continuar polling } }

4. Matching exato de redirect URIs

O que mudou: OAuth 2.0 permitia matching "frouxo" de redirect URIs — matching por prefixo, wildcards em subdomínios e outros padrões flexíveis. OAuth 2.1 exige matching exato de strings.

// ❌ OAuth 2.0: Esses padrões "frouxos" eram permitidos // Registrado: https://app.example.com/callback // Match: https://app.example.com/callback?foo=bar ← OK // Match: https://app.example.com/callback/extra ← OK (prefix match) // Match: https://*.example.com/callback ← OK (wildcard) // ✅ OAuth 2.1: Só matching exato de strings // Registrado: https://app.example.com/callback // Match: https://app.example.com/callback ← OK // Não match: https://app.example.com/callback?foo=bar ← REJEITADO // Não match: https://app.example.com/callback/ ← REJEITADO (barra final) // Não match: https://sub.example.com/callback ← REJEITADO

Por que isso importa mais do que você pensa:

Vulnerabilidades de open redirect eram um dos ataques OAuth mais comuns. Um atacante podia registrar um redirect URI tipo https://evil.com/steal e, se o authorization server usasse prefix matching com uma comparação frouxa, redirecionar o authorization code pro server dele.

Checklist de migração:

// Auditar seus registros de redirect URIs const redirectUris = { // ❌ Problemas pra corrigir: bad: [ 'https://app.example.com/*', // Wildcard — não permitido 'https://app.example.com/auth/callback/', // Mismatch de barra final 'http://localhost:3000/callback', // HTTP em produção ], // ✅ Registros corretos: good: [ 'https://app.example.com/auth/callback', // Match exato, sem barra final 'https://staging.example.com/auth/callback', // Entrada separada pra staging 'http://127.0.0.1:3000/callback', // Loopback pra dev (RFC 8252) 'http://[::1]:3000/callback', // Loopback IPv6 pra dev ], }; // Obs: Pra desenvolvimento, o RFC 8252 permite HTTP em endereços loopback // (127.0.0.1 e [::1]), mas NÃO em "localhost" (problema de resolução DNS)

Requisitos adicionais de segurança

Além das quatro breaking changes, o OAuth 2.1 reforça várias práticas de segurança:

Nada de bearer tokens em URLs

// ❌ OAuth 2.0 permitia isso fetch('https://api.example.com/data?access_token=eyJhbGciOi...'); // Token na URL = logado em access logs do server, proxy, CDN, histórico do browser // ✅ OAuth 2.1: Só header Authorization fetch('https://api.example.com/data', { headers: { 'Authorization': 'Bearer eyJhbGciOi...', }, }); // ✅ Ou no body do POST pra form submissions fetch('https://api.example.com/data', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ access_token: 'eyJhbGciOi...', }), });

Rotação de refresh tokens

O OAuth 2.1 recomenda fortemente (na prática, exige) rotação de refresh tokens. Cada vez que um refresh token é usado, o anterior é invalidado e um novo é emitido.

interface TokenStore { accessToken: string; refreshToken: string; expiresAt: number; } async function refreshAccessToken(store: TokenStore): Promise<TokenStore> { const response = await fetch('https://auth.example.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: store.refreshToken, client_id: CLIENT_ID, }), }); if (!response.ok) { // Refresh token já foi usado ou revogado // Forçar reautenticação throw new AuthenticationRequiredError('Sessão expirada'); } const data = await response.json(); return { accessToken: data.access_token, refreshToken: data.refresh_token, // ← Novo refresh token! expiresAt: Date.now() + data.expires_in * 1000, }; } // 🚨 Crítico: Tratar race conditions // Se duas abas tentam refresh ao mesmo tempo, uma recebe // o novo refresh token e a outra falha porque o antigo // foi invalidado. Use um mutex ou padrão leader election: class TokenRefresher { private refreshPromise: Promise<TokenStore> | null = null; async getValidToken(store: TokenStore): Promise<TokenStore> { if (store.expiresAt > Date.now() + 30_000) { return store; // Ainda válido com 30s de buffer } // Deduplicar tentativas concorrentes de refresh if (!this.refreshPromise) { this.refreshPromise = refreshAccessToken(store).finally(() => { this.refreshPromise = null; }); } return this.refreshPromise; } }

Migração completa: exemplo React SPA

Aqui tá um before/after completo pra uma SPA React típica que usava o Implicit Grant:

// === auth.ts — Módulo de autenticação compatível com OAuth 2.1 === const AUTH_CONFIG = { authority: 'https://auth.example.com', clientId: 'my-spa-client', redirectUri: 'https://app.example.com/auth/callback', // Match exato scope: 'openid profile email', tokenEndpoint: 'https://auth.example.com/token', authorizeEndpoint: 'https://auth.example.com/authorize', }; // --- Utilitários PKCE --- function generateRandomString(length: number): string { const array = new Uint8Array(length); crypto.getRandomValues(array); return btoa(String.fromCharCode(...array)) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } async function sha256(plain: string): Promise<ArrayBuffer> { const encoder = new TextEncoder(); return crypto.subtle.digest('SHA-256', encoder.encode(plain)); } async function generatePKCE(): Promise<{ verifier: string; challenge: string; }> { const verifier = generateRandomString(32); const hashed = await sha256(verifier); const challenge = btoa(String.fromCharCode(...new Uint8Array(hashed))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); return { verifier, challenge }; } // --- Fluxo de Login --- export async function login(): Promise<void> { const { verifier, challenge } = await generatePKCE(); const state = generateRandomString(16); // Guardar PKCE verifier e state no sessionStorage // (sobrevive ao redirect, limpa ao fechar a aba) sessionStorage.setItem('oauth_code_verifier', verifier); sessionStorage.setItem('oauth_state', state); const params = new URLSearchParams({ response_type: 'code', client_id: AUTH_CONFIG.clientId, redirect_uri: AUTH_CONFIG.redirectUri, scope: AUTH_CONFIG.scope, state, code_challenge: challenge, code_challenge_method: 'S256', }); window.location.href = `${AUTH_CONFIG.authorizeEndpoint}?${params.toString()}`; } // --- Callback Handler --- export async function handleCallback(): Promise<{ accessToken: string; refreshToken: string; idToken: string; }> { const params = new URLSearchParams(window.location.search); const code = params.get('code'); const state = params.get('state'); // Verificar state pra prevenir CSRF const savedState = sessionStorage.getItem('oauth_state'); if (!state || state !== savedState) { throw new Error('Parâmetro state inválido — possível ataque CSRF'); } const verifier = sessionStorage.getItem('oauth_code_verifier'); if (!verifier) { throw new Error('PKCE verifier não encontrado — reinicie o fluxo de login'); } // Trocar code por tokens const response = await fetch(AUTH_CONFIG.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: code!, redirect_uri: AUTH_CONFIG.redirectUri, client_id: AUTH_CONFIG.clientId, code_verifier: verifier, }), }); if (!response.ok) { const error = await response.json(); throw new Error(`Falha no token exchange: ${error.error_description}`); } // Limpar sessionStorage.removeItem('oauth_code_verifier'); sessionStorage.removeItem('oauth_state'); // Limpar URL window.history.replaceState({}, '', window.location.pathname); return response.json(); } // --- Gestão de Tokens --- export async function refreshToken( currentRefreshToken: string ): Promise<{ accessToken: string; refreshToken: string; }> { const response = await fetch(AUTH_CONFIG.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: currentRefreshToken, client_id: AUTH_CONFIG.clientId, }), }); if (!response.ok) { // Refresh token rotacionado e já usado, ou revogado throw new Error('SESSION_EXPIRED'); } return response.json(); } // --- Logout --- export async function logout(idToken: string): Promise<void> { // Revogar tokens server-side await fetch('https://auth.example.com/revoke', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: idToken, token_type_hint: 'access_token', client_id: AUTH_CONFIG.clientId, }), }); // Limpar estado local e redirecionar sessionStorage.clear(); window.location.href = 'https://auth.example.com/logout?' + new URLSearchParams({ id_token_hint: idToken, post_logout_redirect_uri: 'https://app.example.com', }).toString(); }

Guia de migração por Identity Provider

Cada IdP principal tem timelines diferentes pra aplicar a semântica do OAuth 2.1:

Auth0 / Okta

// Auth0 SDK v2+ já usa Authorization Code + PKCE por padrão // Migração: Atualize o SDK e remova config legada // ❌ Configuração Auth0 antiga const auth0 = new Auth0Client({ domain: 'your-tenant.auth0.com', clientId: 'YOUR_CLIENT_ID', useRefreshTokens: false, // ← Comum em setups com Implicit cacheLocation: 'localstorage', // ← Necessário sem refresh tokens }); // ✅ Nova configuração Auth0 (compatível com OAuth 2.1) const auth0 = new Auth0Client({ domain: 'your-tenant.auth0.com', clientId: 'YOUR_CLIENT_ID', authorizationParams: { redirect_uri: 'https://app.example.com/callback', // Match exato }, useRefreshTokens: true, // ← Habilitar rotação de refresh tokens cacheLocation: 'memory', // ← Em memória é mais seguro });

Google OAuth

// Google deprecou o Implicit flow pra apps novas em 2022 // Apps existentes: deadline de migração varia // ❌ Antes: Google Sign-In (Implicit) // <script src="https://apis.google.com/js/platform.js"></script> // gapi.auth2.init({ client_id: '...' }) — deprecado // ✅ Agora: Google Identity Services (Authorization Code + PKCE) // Usa a nova lib GIS com code flow google.accounts.oauth2.initCodeClient({ client_id: GOOGLE_CLIENT_ID, scope: 'openid profile email', ux_mode: 'redirect', redirect_uri: 'https://app.example.com/auth/google/callback', state: generateState(), });

Microsoft Entra ID (Azure AD)

// MSAL.js v2+ usa Authorization Code + PKCE por padrão import { PublicClientApplication } from '@azure/msal-browser'; const msalConfig = { auth: { clientId: 'YOUR_CLIENT_ID', authority: 'https://login.microsoftonline.com/YOUR_TENANT_ID', redirectUri: 'https://app.example.com/auth/callback', // Match exato }, cache: { cacheLocation: 'sessionStorage', // ← Mais seguro que localStorage storeAuthStateInCookie: false, }, }; const msalInstance = new PublicClientApplication(msalConfig); // Login — PKCE é automático com MSAL v2+ await msalInstance.loginRedirect({ scopes: ['openid', 'profile', 'User.Read'], });

Segurança avançada além do OAuth 2.1

OAuth 2.1 é o piso, não o teto. Pra apps de produção lidando com dados sensíveis, considere estas medidas adicionais:

DPoP (Demonstration of Proof-of-Possession)

DPoP vincula access tokens a um cliente específico, prevenindo roubo e replay de tokens. Em vez de bearer tokens (que qualquer um pode usar se roubar), tokens DPoP são criptograficamente vinculados ao key pair do cliente.

// DPoP: Tokens com Proof-of-Possession async function createDPoPProof( url: string, method: string, accessToken?: string ): Promise<string> { // Gerar ou recuperar seu key pair DPoP const keyPair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify'] ); const header = { alg: 'ES256', typ: 'dpop+jwt', jwk: await crypto.subtle.exportKey('jwk', keyPair.publicKey), }; const payload = { jti: crypto.randomUUID(), htm: method, htu: url, iat: Math.floor(Date.now() / 1000), // Incluir hash do access token pra token binding ...(accessToken && { ath: await sha256Base64url(accessToken), }), }; return signJWT(header, payload, keyPair.privateKey); } // Uso: Anexar DPoP proof a cada API request const dpopProof = await createDPoPProof( 'https://api.example.com/data', 'GET', accessToken ); fetch('https://api.example.com/data', { headers: { 'Authorization': `DPoP ${accessToken}`, 'DPoP': dpopProof, }, });

Pushed Authorization Requests (PAR)

PAR previne manipulação de authorization requests enviando os parâmetros direto pro server antes do redirect:

// PAR: Enviar parâmetros de autorização pro server primeiro async function initiateLoginWithPAR(): Promise<void> { const { verifier, challenge } = await generatePKCE(); const state = generateRandomString(16); // Passo 1: Enviar authorization request pro server const parResponse = await fetch('https://auth.example.com/par', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: AUTH_CONFIG.clientId, redirect_uri: AUTH_CONFIG.redirectUri, scope: AUTH_CONFIG.scope, response_type: 'code', state, code_challenge: challenge, code_challenge_method: 'S256', }), }); const { request_uri } = await parResponse.json(); // Passo 2: Redirecionar só com o request_uri // (Todos os parâmetros guardados server-side, não na URL) sessionStorage.setItem('oauth_code_verifier', verifier); sessionStorage.setItem('oauth_state', state); window.location.href = `${AUTH_CONFIG.authorizeEndpoint}?` + `client_id=${AUTH_CONFIG.clientId}&` + `request_uri=${encodeURIComponent(request_uri)}`; }

OAuth 2.1 e agentes de IA

Uma das implicações menos discutidas do OAuth 2.1 é seu papel no ecossistema emergente de agentes de IA. Conforme servidores MCP e protocolos A2A amadurecem, o OAuth 2.1 fornece a base de segurança pra acesso delegado de IA.

// OAuth pra Agentes de IA: acesso com scope limitado e tempo limitado // Um agente de IA NUNCA deveria ter permissões totais do usuário const agentTokenRequest = { grant_type: 'authorization_code', code: authorizationCode, code_verifier: pkceVerifier, client_id: 'ai-agent-client', redirect_uri: 'https://agent.example.com/oauth/callback', // ← Scopes restritos pra agentes de IA scope: 'read:emails read:calendar', // NÃO 'write:*' // ← Pedir tokens de curta duração // (Tarefas de agentes não precisam de acesso de vários dias) }; // Pra autenticação de servidores MCP: // A spec do MCP recomenda OAuth 2.1 com PKCE // pra acesso a ferramentas de terceiros via agentes de IA. // Isso garante que os usuários consintam explicitamente // com quais dados o agente de IA pode acessar em nome deles.

Por que importa: Um agente de IA que acessa seus repos no GitHub, canais do Slack e caixa de email precisa de autorização verificável criptograficamente, com scope restrito e tempo limitado — não uma API key estática numa variável de ambiente. OAuth 2.1 com PKCE + DPoP fornece exatamente isso.

Erros comuns de migração

Erro 1: Guardar o PKCE verifier no localStorage

// ❌ Ruim: localStorage persiste entre sessões localStorage.setItem('pkce_verifier', verifier); // Um ataque XSS pode ler isso e completar o code exchange // ✅ Bom: sessionStorage limpa ao fechar a aba sessionStorage.setItem('pkce_verifier', verifier); // Ainda vulnerável a XSS, mas com janela de exposição menor // ✅ Melhor: Guardar server-side em cookie HTTP-only // (pra padrões BFF / Backend-for-Frontend)

Erro 2: Não tratar race conditions do refresh token

// ❌ Ruim: Duas API calls concorrentes disparam dois refreshes // Aba 1: refresh_token=abc → recebe novo refresh_token=def // Aba 2: refresh_token=abc → FALHA (abc já foi rotacionado) // Aba 2 desloga o usuário desnecessariamente // ✅ Bom: Centralizar refresh de tokens com um mutex class TokenManager { private refreshLock: Promise<void> | null = null; async getAccessToken(): Promise<string> { const tokens = this.getStoredTokens(); if (this.isExpired(tokens)) { if (!this.refreshLock) { this.refreshLock = this.doRefresh().finally(() => { this.refreshLock = null; }); } await this.refreshLock; } return this.getStoredTokens().accessToken; } }

Erro 3: Ignorar o parâmetro state

// ❌ Ruim: Sem parâmetro state = sem proteção CSRF const authUrl = `${authEndpoint}?response_type=code&client_id=${clientId}`; // Um atacante pode criar uma URL que loga a vítima na conta do atacante // ✅ Bom: Sempre gerar e validar state const state = crypto.randomUUID(); sessionStorage.setItem('oauth_state', state); const authUrl = `${authEndpoint}?response_type=code&client_id=${clientId}&state=${state}`; // No callback: verificar que o state corresponde antes de trocar o code

Erro 4: Usar "localhost" pra redirects de desenvolvimento

// ❌ Ruim: "localhost" resolve via DNS (pode ser hijacked) const devRedirect = 'http://localhost:3000/callback'; // ✅ Bom: Usar endereços IP loopback (RFC 8252) const devRedirect = 'http://127.0.0.1:3000/callback'; // Ou IPv6: 'http://[::1]:3000/callback' // Resolvem localmente sem DNS, prevenindo redirect hijacking

O checklist de migração

Use este checklist pra auditar sua implementação OAuth existente:

## Checklist de Migração OAuth 2.1 ### Crítico (Corrigir antes do IdP forçar) - [ ] Remover todo uso de `response_type=token` (Implicit Grant) - [ ] Remover todo uso de `grant_type=password` (ROPC) - [ ] Adicionar PKCE a todos os authorization code flows - [ ] Mudar pra matching exato de redirect URIs - [ ] Remover bearer tokens de query strings de URLs ### Alta Prioridade - [ ] Implementar rotação de refresh tokens - [ ] Tratar race conditions do refresh token (padrão mutex) - [ ] Guardar PKCE verifiers em sessionStorage, não localStorage - [ ] Migrar redirects de desenvolvimento de localhost pra 127.0.0.1 - [ ] Adicionar parâmetro state a todos os authorization requests ### Recomendado - [ ] Implementar DPoP pra token binding de alta segurança - [ ] Usar PAR (Pushed Authorization Requests) pra fluxos sensíveis - [ ] Limitar scope de tokens de agentes IA (só leitura, tempo limitado) - [ ] Auditar libs de terceiros pra conformidade com OAuth 2.1 - [ ] Configurar revogação de tokens no logout

Conclusão

OAuth 2.1 não adiciona complexidade — remove as arestas que causaram uma década de incidentes de segurança. O Implicit Grant foi um atalho que criou vulnerabilidades reais. ROPC foi um caminho de migração que virou permanente. Wildcards em redirects eram convenientes até deixarem de ser.

A migração é direta pra maioria das aplicações:

  1. Substitua Implicit Grant por Authorization Code + PKCE. Essa é a mudança de maior impacto. Se você usa um SDK moderno (Auth0, MSAL, Firebase), atualizar a versão do SDK muitas vezes resolve automaticamente.

  2. Remova os fluxos ROPC. Mude pro substituto adequado: Authorization Code pra apps voltadas a usuários, Client Credentials pra M2M, e Device Authorization pra CLIs.

  3. Audite suas redirect URIs. Registre cada ambiente (produção, staging, desenvolvimento) como URI de match exato. Use 127.0.0.1 em vez de localhost pra desenvolvimento local.

  4. Habilite rotação de refresh tokens. Implemente o padrão mutex pra tratar tentativas concorrentes de refresh entre abas.

  5. Pare de colocar tokens em URLs. Use o header Authorization exclusivamente.

Os Identity Providers já estão se movendo — Auth0, Okta, Google e Microsoft deprecaram fluxos legados ou estabeleceram timelines de enforcement. Migrar agora, no seu próprio ritmo, é significativamente menos doloroso do que migrar sob pressão de deadline quando sua autenticação começa a retornar erros 400.

OAuthsecurityauthenticationPKCETypeScriptAPIweb-securityidentitySSOmigration

Explore ferramentas relacionadas

Experimente estas ferramentas gratuitas do Pockit