Use Cases (업데이트: 2026. 6. 2.)

Claude Code OAuth 구현: PKCE, state, nonce, 안전한 토큰 저장

OAuth 2.1/2.0 Authorization Code + PKCE를 Claude Code로 구현하는 실전 가이드. state, nonce, 토큰 저장까지 다룹니다.

Claude Code OAuth 구현: PKCE, state, nonce, 안전한 토큰 저장

OAuth는 “Google로 로그인” 버튼을 붙이면 끝나는 기능이 아닙니다. 작은 실수로 다른 계정이 연결되거나, authorization code가 재사용되거나, redirect URI가 바뀌거나, 토큰이 브라우저 JavaScript에 노출될 수 있습니다. 이 글은 Claude Code로 OAuth 구현을 스캐폴딩하면서 실제 비밀값을 넘기지 않고, 보안 기능처럼 리뷰하는 방법을 설명합니다.

실무 기본값은 OAuth 2.0 Authorization Code + PKCE이며 OAuth 2.1의 방향과도 맞습니다. PKCE는 로그인 시작 시 해시된 challenge를 보내고, 토큰 교환 시 원래 verifier를 가진 클라이언트임을 증명하는 방식입니다. 관련 글로 Claude Code 시작 가이드, Claude Code 인증 구현, Claude Code API 개발을 함께 보세요.

구현 원칙

Claude Code에는 라우트, 세션, 테스트, 리뷰 체크리스트 생성을 맡기고, 실제 client secret, refresh token, 운영 .env, 공급자 콘솔 화면은 주지 않습니다. 변수명, 기대 동작, 실패 조건만 전달합니다.

항목권장이유
FlowAuthorization Code + PKCE오래된 implicit 방식의 토큰 노출을 피함
CSRFstate 저장 및 검증위조된 콜백 완료를 차단
OIDC replaynonce 저장 및 검증재사용된 인증 결과를 탐지
redirect URI정확한 허용 목록 매칭리다이렉트 교체 공격 방지
Token서버 세션 또는 암호화 저장소긴 수명의 토큰을 localStorage에 두지 않음
Claude Code 입력더미 값과 사양만프롬프트와 로그에 비밀이 남지 않음

공식 기준은 OAuth 2.1, RFC 7636 PKCE, RFC 9700 OAuth 2.0 Security BCP, OpenID Connect Core, OWASP OAuth2 Cheat Sheet, Claude Code Security입니다.

실제 유스케이스 3가지

첫째는 B2B SaaS 관리자 화면입니다. Google Workspace나 Microsoft Entra ID로 로그인하지만, 앱 내부에서는 사용자, 조직, 역할로 다시 매핑해야 합니다. OAuth는 로그인 증명이고 권한은 앱이 결정합니다.

둘째는 외부 API 연동입니다. 캘린더, 메일, 스토리지, CRM 연동은 사용자 동의와 refresh token 관리가 필요합니다. scope는 최소화하고 refresh token은 암호화하며 연결 해제 기능을 제공해야 합니다.

셋째는 내부 도구와 MCP 스타일 서비스입니다. 정적 API key로 시작하면 나중에 회수, 감사, 권한 분리가 어렵습니다. OAuth/OIDC는 로그인, 동의, 만료, 신원 경계를 더 명확히 해 줍니다.

모바일 앱과 SPA는 client secret을 숨길 수 없으므로 PKCE가 필수입니다. 서버 렌더링 웹 앱도 지금은 PKCE를 기본으로 넣는 편이 리뷰하기 좋습니다.

그대로 실행할 수 있는 로컬 데모

아래 데모는 Google, Microsoft, Auth0 자격 증명 없이 동작합니다. 하나의 Express 프로세스가 OAuth 클라이언트와 모의 authorization server를 모두 맡아 state, nonce, PKCE S256, redirect URI 정확 매칭, 1회용 authorization code, 서버 세션 토큰 저장을 보여 줍니다.

빈 폴더에 두 파일을 만들고 npm install && npm start를 실행한 뒤 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"));

확인할 핵심은 /auth/loginstate, nonce, code_verifier를 서버 세션에 저장하고, 요청에는 code_challenge만 보낸다는 점입니다. /callbackstate를 확인하고, /mock/token은 verifier로 S256 값을 다시 계산하며, 토큰은 localStorage가 아니라 서버 세션에 남습니다.

Claude Code 프롬프트

Express + TypeScript로 OAuth 2.0 Authorization Code + PKCE를 구현해 주세요.
조건:
- 공급자 설정은 환경 변수명만 정의하고 실제 secret은 쓰지 않습니다.
- state, nonce, code_verifier는 서버 세션에 저장합니다.
- redirect_uri는 설정값과 정확히 일치하는지 검증합니다.
- code_challenge_method=S256만 허용합니다.
- access token과 refresh token을 localStorage에 저장하지 않습니다.
- refresh token은 암호화 DB 필드 또는 서버 세션에만 저장합니다.
- 성공, state 불일치, PKCE 불일치, 만료 코드, 코드 재사용 테스트를 추가합니다.
- 마지막에 보안 리뷰 체크리스트를 출력합니다.

리뷰에는 다음을 추가합니다.

RFC 9700, RFC 7636, OWASP OAuth2 Cheat Sheet 기준으로 이 OAuth 구현을 리뷰해 주세요.
secret이 로그, 테스트 스냅샷, 프런트엔드 번들, Git diff에 나타나는지도 확인해 주세요.
위험도를 High/Medium/Low로 나누고 수정 패치를 제안해 주세요.

흔한 함정

redirect URI를 prefix로 검증하지 마세요. https://app.example.com.evil.test/callback은 내 앱이 아닙니다. 등록된 URI와 정확히 비교해야 합니다.

state를 만들기만 하고 검증하지 않는 실수도 많습니다. 로그인 시작 때 저장하고 콜백에서 비교하며 사용 후 삭제합니다. 여러 탭을 지원해야 하면 state를 키로 짧은 트랜잭션을 보관합니다.

PKCE에서는 plain 허용, verifier 로그 출력, authorization code 재사용 허용이 위험합니다. S256만 허용하고 코드는 짧게 만료시키며 한 번만 사용합니다.

OIDC ID Token을 사용할 때는 nonce뿐 아니라 서명, issuer, audience, expiry도 검증합니다. ID Token은 신원 증거이고 Access Token은 API 권한입니다. 둘을 섞으면 신뢰 경계가 흐려집니다.

긴 수명의 토큰을 localStorage에 두지 마세요. 일반 웹 앱은 서버 세션, 암호화된 토큰 테이블, 짧은 Cookie 수명, 명시적 revoke가 더 안전합니다. Cookie는 Claude Code Cookie 관리를 참고하세요.

상용 품질 체크리스트

  • 토큰 수명과 refresh 동작이 문서화되어 있다.
  • 팀이 state, nonce, PKCE를 자기 말로 설명할 수 있다.
  • redirect URI, scope, 공급자 콘솔 설정이 기록되어 있다.
  • 로그에 code, verifier, token, cookie 값이 없다.
  • 실패 메시지가 내부 세부 정보를 노출하지 않는다.
  • 성공, 불일치, 만료, 재사용 테스트가 있다.
  • Claude Code 출력이 공식 문서 기준으로 리뷰되었다.

ClaudeCodeLab의 교육과 컨설팅은 이 절차를 팀 워크숍으로 바꿀 수 있습니다. 기존 OAuth 흐름을 가져와 위험을 정리하고 테스트를 추가하며 재사용 가능한 리뷰 기준을 남기는 방식입니다. 자세한 형식은 ClaudeCodeLab training을 참고하세요.

직접 검증한 결과

이 데모는 로그인 시작, 모의 인증, 콜백, 토큰 교환, dashboard까지 로컬에서 확인하도록 작성했습니다. 반환된 state를 바꾸면 state mismatch로 멈추고, verifier가 틀리면 invalid_code_verifier가 반환됩니다. 예전에 OAuth 프로토타입을 만들 때 리뷰에서 자주 빠진 것은 정상 흐름이 아니라 여러 탭, 뒤로 가기, 만료 코드, 로그 출력이었습니다. 이 실패 사례를 Claude Code 첫 프롬프트에 넣으면 결과물이 훨씬 안정적입니다.

정리

OAuth 품질은 로그인 버튼이 아니라 전체 거래를 보호하는 데서 나옵니다. Authorization Code + PKCE, state, nonce, 정확한 redirect URI, 서버 측 토큰 관리를 기본으로 삼으세요. Claude Code는 속도를 높여 주지만 실제 secret을 주지 않고 공식 문서로 리뷰해야 합니다.

#Claude Code #OAuth #authentication #security #TypeScript
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.