OAuth com Claude Code: PKCE, state, nonce e armazenamento seguro de tokens
Guia prático de OAuth 2.1/2.0 Authorization Code + PKCE com Claude Code, cobrindo state, nonce e tokens.
OAuth não termina quando o botão “Entrar com Google” aparece. Um detalhe errado pode vincular a conta errada, reutilizar um código de autorização, trocar o redirect URI ou expor tokens ao JavaScript do navegador. Este guia mostra como usar Claude Code para criar a base sem entregar segredos reais e como revisar a implementação.
O padrão prático é OAuth 2.0 Authorization Code + PKCE, alinhado com OAuth 2.1. PKCE permite que o app prove, no endpoint de token, que ainda possui o verifier original ligado ao challenge enviado no início. Para contexto, veja autenticação com Claude Code, desenvolvimento de API e primeiros passos.
Estrutura recomendada
Use Claude Code para rotas, sessões, testes e checklists. Não cole client secrets, refresh tokens, .env de produção ou capturas da console do provedor. Passe nomes de variáveis, comportamento esperado e casos de falha.
| Área | Recomendação | Motivo |
|---|---|---|
| Fluxo | Authorization Code + PKCE | Evita exposição de tokens em fluxos antigos |
| CSRF | Salvar e validar state | Bloqueia callback falsificado |
| Replay OIDC | Salvar e validar nonce | Detecta identidade reutilizada |
| Redirect URI | Correspondência exata | Impede troca de destino |
| Tokens | Sessão no servidor ou store criptografado | Mantém tokens longos fora do localStorage |
| Entrada no Claude Code | Valores fictícios e especificação | Evita segredos em prompts e logs |
Fontes primárias: OAuth 2.1, RFC 7636 PKCE, RFC 9700 OAuth 2.0 Security BCP, OpenID Connect Core, OWASP OAuth2 Cheat Sheet, Claude Code Security.
Três casos reais
O primeiro é um painel B2B SaaS. Usuários entram com Google Workspace ou Microsoft Entra ID, mas o app ainda mapeia usuário, organização e papel internos. OAuth prova identidade; sua autorização decide permissões.
O segundo é acesso delegado a APIs. Calendário, e-mail, armazenamento e CRM exigem consentimento, scopes mínimos, refresh tokens criptografados e fluxo de desconexão.
O terceiro são ferramentas internas e serviços no estilo MCP. API keys estáticas são simples no início, mas dificultam revogação e auditoria. OAuth/OIDC separa login, consentimento, expiração e identidade.
Apps móveis e SPAs não escondem client secret, então PKCE é obrigatório. Mesmo em apps web com servidor, PKCE facilita revisão.
Demo local copiável
A demo não precisa de Google, Microsoft ou Auth0. Um processo Express atua como cliente OAuth e servidor de autorização simulado, mostrando state, nonce, PKCE S256, redirect URI exato, códigos de uso único e tokens em sessão do servidor.
Crie os arquivos em uma pasta vazia, rode npm install && npm start e abra http://localhost:3000.
{
"scripts": { "start": "node server.mjs" },
"dependencies": { "express": "^4.19.2", "express-session": "^1.18.0" },
"engines": { "node": ">=20" }
}
// server.mjs
import crypto from "node:crypto";
import express from "express";
import session from "express-session";
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(session({
name: "oauth_demo_sid",
secret: "dev-only-change-this-32-byte-secret",
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: "lax", secure: false, maxAge: 10 * 60 * 1000 },
}));
const client = {
clientId: "claude-code-demo",
redirectUri: "http://localhost:3000/callback",
scope: "openid profile email",
};
const authorizationEndpoint = "http://localhost:3000/mock/authorize";
const tokenEndpoint = "http://localhost:3000/mock/token";
const registeredRedirectUris = new Set([client.redirectUri]);
const pendingCodes = new Map();
function randomUrlSafe(bytes = 32) {
return crypto.randomBytes(bytes).toString("base64url");
}
function sha256Base64Url(value) {
return crypto.createHash("sha256").update(value).digest("base64url");
}
function fail(res, status, message) {
return res.status(status).type("text/plain").send(message);
}
app.get("/", (_req, res) => {
res.type("html").send(`<h1>OAuth PKCE local demo</h1><p><a href="/auth/login">Start login</a></p>`);
});
app.get("/auth/login", (req, res) => {
const state = randomUrlSafe();
const nonce = randomUrlSafe();
const codeVerifier = randomUrlSafe(48);
const codeChallenge = sha256Base64Url(codeVerifier);
req.session.oauth = { state, nonce, codeVerifier, createdAt: Date.now() };
const params = new URLSearchParams({
response_type: "code",
client_id: client.clientId,
redirect_uri: client.redirectUri,
scope: client.scope,
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
res.redirect(`${authorizationEndpoint}?${params}`);
});
app.get("/mock/authorize", (req, res) => {
const p = req.query;
const redirectUri = String(p.redirect_uri || "");
if (p.response_type !== "code") return fail(res, 400, "response_type must be code");
if (p.client_id !== client.clientId) return fail(res, 400, "unknown client_id");
if (!registeredRedirectUris.has(redirectUri)) return fail(res, 400, "redirect_uri is not registered exactly");
if (p.code_challenge_method !== "S256") return fail(res, 400, "PKCE S256 is required");
if (!p.code_challenge || !p.state || !p.nonce) return fail(res, 400, "missing state, nonce, or PKCE challenge");
const code = randomUrlSafe(24);
pendingCodes.set(code, {
clientId: client.clientId,
redirectUri,
codeChallenge: String(p.code_challenge),
nonce: String(p.nonce),
expiresAt: Date.now() + 60_000,
used: false,
});
const redirect = new URL(redirectUri);
redirect.searchParams.set("code", code);
redirect.searchParams.set("state", String(p.state));
res.redirect(redirect.toString());
});
app.get("/callback", async (req, res) => {
const oauth = req.session.oauth;
const code = String(req.query.code || "");
const returnedState = String(req.query.state || "");
if (!oauth) return fail(res, 400, "missing OAuth session");
if (returnedState !== oauth.state) return fail(res, 403, "state mismatch: possible CSRF or mixed login attempt");
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: client.redirectUri,
client_id: client.clientId,
code_verifier: oauth.codeVerifier,
}),
});
const tokens = await response.json();
if (!response.ok) return fail(res, response.status, JSON.stringify(tokens, null, 2));
if (tokens.nonce !== oauth.nonce) return fail(res, 403, "nonce mismatch: possible replay");
req.session.oauth = undefined;
req.session.tokenSet = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + tokens.expires_in * 1000,
};
res.redirect("/dashboard");
});
app.post("/mock/token", (req, res) => {
const body = req.body;
const record = pendingCodes.get(body.code);
if (body.grant_type !== "authorization_code") return res.status(400).json({ error: "unsupported_grant_type" });
if (!record || record.used || record.expiresAt < Date.now()) return res.status(400).json({ error: "invalid_grant" });
if (body.client_id !== record.clientId) return res.status(400).json({ error: "invalid_client" });
if (body.redirect_uri !== record.redirectUri) return res.status(400).json({ error: "invalid_redirect_uri" });
if (sha256Base64Url(body.code_verifier || "") !== record.codeChallenge) return res.status(400).json({ error: "invalid_code_verifier" });
record.used = true;
res.json({ token_type: "Bearer", access_token: randomUrlSafe(32), refresh_token: randomUrlSafe(32), expires_in: 300, nonce: record.nonce });
});
app.get("/dashboard", (req, res) => {
const tokenSet = req.session.tokenSet;
if (!tokenSet) return res.redirect("/auth/login");
const secondsLeft = Math.max(0, Math.floor((tokenSet.expiresAt - Date.now()) / 1000));
res.type("html").send(`<h1>Logged in</h1><p>Access token is stored server-side, not in localStorage.</p><p>Expires in ${secondsLeft} seconds.</p>`);
});
app.listen(3000, () => console.log("Open http://localhost:3000"));
Observe: /auth/login guarda state, nonce e code_verifier na sessão; a requisição envia só code_challenge; /callback valida state; /mock/token recalcula S256; /dashboard lê tokens do servidor, não do localStorage.
Prompt para Claude Code
Implemente OAuth 2.0 Authorization Code + PKCE em Express + TypeScript.
Requisitos:
- Definir configuração do provedor apenas como nomes de variáveis de ambiente; sem segredos reais.
- Guardar state, nonce e code_verifier em sessão no servidor.
- Validar redirect_uri por correspondência exata.
- Permitir somente code_challenge_method=S256.
- Nunca salvar access token ou refresh token em localStorage.
- Salvar refresh tokens apenas em campo criptografado ou sessão no servidor.
- Adicionar testes de sucesso, state inválido, PKCE inválido, código expirado e reuso.
- Finalizar com checklist de segurança.
Para revisar:
Revise esta implementação OAuth contra RFC 9700, RFC 7636 e OWASP OAuth2 Cheat Sheet.
Verifique segredos em logs, snapshots, bundles frontend e Git diff.
Classifique achados como High, Medium ou Low e proponha patches.
Armadilhas comuns
Não valide redirect URI por prefixo. https://app.example.com.evil.test/callback não é seu app. Use correspondência exata.
Não gere state sem validar. Salve no início, compare no callback e remova depois. Para várias abas, guarde transações curtas por state.
Não permita PKCE plain sem motivo forte. Prefira S256, não registre o verifier, expire códigos rapidamente e use uma única vez.
Com OIDC, nonce não basta. Valide assinatura JWT, issuer, audience, expiração e nonce. ID Token é identidade; Access Token é autorização de API.
Evite localStorage para tokens longos. Use sessões no servidor, tabelas criptografadas, cookies curtos e revogação. Veja Claude Code cookie management.
Checklist de entrega profissional
- Tempo de vida dos tokens e refresh documentados.
- O time explica
state,noncee PKCE com palavras simples. - Redirect URIs, scopes e configurações do provedor registrados.
- Logs sem code, verifier, token ou cookie.
- Erros não expõem detalhes internos.
- Testes cobrem sucesso, mismatch, expiração e reuso.
- Saída do Claude Code revisada contra fontes oficiais.
Treinamento e consultoria ClaudeCodeLab podem transformar isso em workshop: trazer um fluxo OAuth real, mapear riscos, adicionar testes e sair com checklist reutilizável. Veja ClaudeCodeLab training.
Verificação prática
A demo percorre início do login, autorização simulada, callback, troca de token e dashboard. Alterar state gera state mismatch; verifier errado retorna invalid_code_verifier. Em protótipos OAuth, os problemas que escapam costumam ser múltiplas abas, botão voltar, códigos expirados e logs acidentais. Inclua esses casos no primeiro prompt ao Claude Code.
Resumo
A qualidade de OAuth está em proteger a transação inteira. Use Authorization Code + PKCE, valide state e nonce, compare redirect URI exatamente e mantenha tokens no servidor. Claude Code acelera, mas precisa de restrições explícitas e revisão por fontes primárias.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.