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.
Artigos relacionados
Autenticação segura com Claude Code: sessões Next.js, JWT e OAuth
Implemente autenticação segura com Claude Code: sessões Next.js, JWT, OAuth, CSRF, RBAC, auditoria e testes.
Claude Code e AWS IAM: guia prático de políticas de menor privilégio
Projete e valide políticas AWS IAM de menor privilégio com Claude Code, Access Analyzer e CDK.
Como implementar perfis de usuário seguros com Claude Code
Crie perfis com banco de dados, formulário, validação, autorização, avatar e logs de auditoria.
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.