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

Claude Code로 JWT 인증을 안전하게 구현하는 실전 가이드

Claude Code로 JWT 인증을 구현하며 claim, Cookie, refresh rotation, 폐기, 키 교체와 안전한 prompt를 다룹니다.

Claude Code로 JWT 인증을 안전하게 구현하는 실전 가이드

JWT 인증은 데모만 보면 아주 간단합니다. payload에 서명하고 token을 반환한 뒤 middleware에서 검증하면 끝처럼 보입니다. 하지만 운영 환경에서는 aud 검증 누락, 너무 긴 refresh token, localStorage 저장, 급한 키 교체 같은 작은 결정이 계정 탈취로 이어질 수 있습니다.

이 글은 초보자도 이해할 수 있는 JWT 기본부터 Claude Code에 넘길 수 있는 구현 지시서까지 정리합니다. claim 설계, 서명과 암호화의 차이, Cookie와 session 배치, refresh token rotation, 폐기, 키 로테이션, 흔한 실패, 안전한 prompt를 다룹니다. 로그인 전체 흐름은인증 구현 가이드, Cookie 세부 사항은Cookie 관리, 권한 설계는RBAC 구현과 함께 보면 좋습니다.

기준 문서는 원문을 확인하세요. JWT 사양은RFC 7519, 보안 지침은RFC 8725, refresh token 재사용 탐지는RFC 9700, 구현상 주의점은OWASP JWT Cheat Sheet, Cookie 속성은MDN Set-Cookie, Node 구현은jose, Claude Code 권한 경계는Claude Code settings를 기준으로 삼았습니다.

초보자를 위한 JWT 기본

JWT는 header.payload.signature 세 부분을 점으로 연결한 문자열입니다. header에는 token 타입과 서명 알고리즘이 있고, payload에는 claim이 들어갑니다. claim은 token에 대한 주장입니다. 예를 들어 sub는 사용자 ID, iss는 발급자, aud는 대상 API, exp는 만료 시간, jti는 token ID입니다.

가장 중요한 점은 일반적인 JWT가 암호화가 아니라 서명이라는 사실입니다. 서명은 payload가 변조되지 않았음을 확인하지만 payload를 숨기지 않습니다. token을 얻은 사람은 누구나 payload를 디코딩할 수 있습니다. 따라서 API key, 주소, 결제 정보, 내부 메모, 민감한 개인 정보는 넣지 마세요. 숨겨야 하는 데이터는 서버 session에 두고 ID만 token에 넣는 편이 보통 더 단순하고 안전합니다.

Claude Code에는 먼저 이 전제를 고정해서 전달합니다.

JWT 인증을 구현해 주세요.
규칙:
- JWT payload는 서명된 값이지 암호화된 값이 아니다. secret을 넣지 않는다.
- access token은 15분 이하로 만료한다.
- refresh token은 7일 이하로 만료한다.
- iss, aud, sub, exp, iat, jti를 검증한다.
- alg none과 예상하지 않은 알고리즘을 거부한다.
- refresh token rotation과 reuse detection을 구현한다.
- Cookie, CSRF, XSS, 폐기, 키 로테이션을 리뷰에 포함한다.

claim은 최소로 설계한다

JWT를 사용자 프로필 캐시로 쓰지 마세요. access token에는 API 입구에서 필요한 최소 정보만 넣습니다. 안정적인 사용자 ID, session ID, tenant ID, token ID, 대략적인 role 정도가 적절합니다. 요금제, 정지 상태, 세부 권한, 사용량처럼 자주 바뀌는 값은 서버에서 DB나 cache로 다시 확인해야 합니다.

claim목적주의점
sub사용자 ID이메일보다 내부 ID가 안전하다
iss발급자인증 서버 주소로 고정한다
aud대상다른 API용 token을 거부한다
exp만료access token은 짧게 둔다
jtitoken ID폐기와 감사 로그에 사용한다
sidsession ID기기별 logout과 token family에 사용한다
role대략적 역할세부 권한은 서버에서 재확인한다

plan: "pro"disabled: false같은 값을 token에 넣으면 상태 변경이 늦게 반영됩니다. 인증은 “누구인가”를 확인하고, 인가는 “지금 이 작업을 허용할 것인가”를 확인합니다. 두 질문을 같은 payload에 몰아넣지 않는 것이 운영에서 더 안전합니다.

복사해서 실행할 수 있는 TypeScript 예제

아래 예제는 jose로 access token 서명과 검증, refresh token hash 저장, rotation, 재사용 탐지를 구현합니다. 운영에서는 메모리 Map을 Redis나 DB로 바꾸고 HTTPS, rate limit, 감사 로그, CSRF 방어를 추가하세요.

mkdir jwt-lab
cd jwt-lab
npm init -y
npm install jose
npm install -D tsx typescript @types/node
// auth-demo.ts
import { createHash, createSecretKey, randomUUID } from "node:crypto";
import { SignJWT, jwtVerify } from "jose";

const ISSUER = "https://auth.example.com";
const AUDIENCE = "claudecodelab-api";
const ACCESS_TTL = "15m";
const REFRESH_TTL_SECONDS = 60 * 60 * 24 * 7;

const accessKey = createSecretKey(
  Buffer.from(
    process.env.JWT_ACCESS_SECRET ??
      "dev-only-secret-change-me-32-bytes-minimum"
  )
);

const refreshKey = createSecretKey(
  Buffer.from(
    process.env.JWT_REFRESH_SECRET ??
      "dev-only-refresh-secret-change-me-32-bytes"
  )
);

type Role = "admin" | "user" | "viewer";
type User = { id: string; role: Role; tenantId: string };
type VerifiedAccess = {
  userId: string;
  role: Role;
  tenantId: string;
  sessionId: string;
  tokenId: string;
};

type RefreshRecord = {
  userId: string;
  sessionId: string;
  tokenHash: string;
  expiresAt: number;
  revokedAt?: number;
};

const refreshStore = new Map<string, RefreshRecord>();
const revokedAccessTokenIds = new Set<string>();

function sha256(value: string) {
  return createHash("sha256").update(value).digest("hex");
}

function assertRole(value: unknown): asserts value is Role {
  if (!["admin", "user", "viewer"].includes(String(value))) {
    throw new Error("invalid role claim");
  }
}

async function signAccessToken(user: User, sessionId: string) {
  const tokenId = randomUUID();

  return new SignJWT({ role: user.role, tid: user.tenantId, sid: sessionId })
    .setProtectedHeader({ alg: "HS256", typ: "JWT" })
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE)
    .setSubject(user.id)
    .setIssuedAt()
    .setExpirationTime(ACCESS_TTL)
    .setJti(tokenId)
    .sign(accessKey);
}

async function verifyAccessToken(token: string): Promise<VerifiedAccess> {
  const { payload } = await jwtVerify(token, accessKey, {
    issuer: ISSUER,
    audience: AUDIENCE,
    algorithms: ["HS256"],
  });

  assertRole(payload.role);

  if (
    typeof payload.sub !== "string" ||
    typeof payload.tid !== "string" ||
    typeof payload.sid !== "string" ||
    typeof payload.jti !== "string"
  ) {
    throw new Error("missing required claim");
  }

  if (revokedAccessTokenIds.has(payload.jti)) {
    throw new Error("access token revoked");
  }

  return {
    userId: payload.sub,
    role: payload.role,
    tenantId: payload.tid,
    sessionId: payload.sid,
    tokenId: payload.jti,
  };
}

async function signRefreshToken(user: User, sessionId: string) {
  const tokenId = randomUUID();
  const token = await new SignJWT({ sid: sessionId, kind: "refresh" })
    .setProtectedHeader({ alg: "HS256", typ: "JWT" })
    .setIssuer(ISSUER)
    .setAudience("claudecodelab-refresh")
    .setSubject(user.id)
    .setIssuedAt()
    .setExpirationTime("7d")
    .setJti(tokenId)
    .sign(refreshKey);

  refreshStore.set(tokenId, {
    userId: user.id,
    sessionId,
    tokenHash: sha256(token),
    expiresAt: Date.now() + REFRESH_TTL_SECONDS * 1000,
  });

  return token;
}

async function rotateRefreshToken(refreshToken: string, user: User) {
  const { payload } = await jwtVerify(refreshToken, refreshKey, {
    issuer: ISSUER,
    audience: "claudecodelab-refresh",
    algorithms: ["HS256"],
  });

  if (
    typeof payload.jti !== "string" ||
    typeof payload.sid !== "string" ||
    typeof payload.sub !== "string"
  ) {
    throw new Error("invalid refresh token claims");
  }

  const record = refreshStore.get(payload.jti);
  const presentedHash = sha256(refreshToken);

  if (!record || record.revokedAt || record.tokenHash !== presentedHash) {
    for (const item of refreshStore.values()) {
      if (item.sessionId === payload.sid) item.revokedAt = Date.now();
    }
    throw new Error("refresh token reuse detected");
  }

  if (record.expiresAt < Date.now()) {
    throw new Error("refresh token expired");
  }

  record.revokedAt = Date.now();

  return {
    accessToken: await signAccessToken(user, payload.sid),
    refreshToken: await signRefreshToken(user, payload.sid),
  };
}

async function main() {
  const user: User = {
    id: "user_123",
    role: "admin",
    tenantId: "tenant_a",
  };
  const sessionId = randomUUID();
  const accessToken = await signAccessToken(user, sessionId);
  const refreshToken = await signRefreshToken(user, sessionId);
  const verified = await verifyAccessToken(accessToken);
  const rotated = await rotateRefreshToken(refreshToken, user);

  console.log({ verified, rotatedRefreshLength: rotated.refreshToken.length });
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});
npx tsx auth-demo.ts

핵심은 refresh token 원문을 저장하지 않는 점입니다. 저장소에는 hash만 남깁니다. refresh token을 한 번 사용하면 기존 record를 폐기하고 새 pair를 발급합니다. 이미 사용된 token이 다시 오면 같은sid의 token family를 폐기합니다.

Cookie와 session 배치

브라우저 앱에서는 refresh token을HttpOnly, Secure, SameSite Cookie에 두는 방식이 현실적입니다. HttpOnly는 JavaScript가 Cookie 값을 직접 읽지 못하게 합니다. XSS가 발생해도 token 문자열을 바로 훔치기 어렵습니다. 하지만 Cookie는 자동 전송되므로 refresh, logout, profile update 같은 상태 변경 요청에는 CSRF 방어가 필요합니다.

const refreshCookieOptions = {
  httpOnly: true,
  secure: true,
  sameSite: "lax" as const,
  path: "/api/auth/refresh",
  maxAge: 60 * 60 * 24 * 7,
};

const clearRefreshCookieOptions = {
  ...refreshCookieOptions,
  maxAge: 0,
};

access token은 구조에 따라 달라집니다. 순수 SPA는 memory에 보관할 수 있지만 새로고침 시 사라집니다. BFF나 Next.js Route Handler를 쓰면 access token을 브라우저에 주지 않고 서버가 API를 대리 호출할 수 있습니다. localStorage는 편하지만 XSS가 있으면 bearer token이 읽히므로 장기 token 저장소로는 부적합합니다.

폐기와 키 로테이션

JWT는 기본적으로exp까지 유효합니다. 그래서 logout, 비밀번호 변경, 관리자에 의한 정지, 기기 분실, 유출 의심 상황에는 서버 측 제어가 필요합니다. 짧은 access token, jti 폐기 목록, sid 단위 session 폐기, refresh token rotation을 조합하세요.

키 로테이션에서는 겹치는 기간이 중요합니다. HS256은 간단하지만 모든 검증자가 같은 secret을 알아야 합니다. 서비스가 늘면 RS256이나 ES256과 JWKS가 더 운영하기 쉽습니다. 새 public key를 먼저 공개하고, 새kid로 서명을 시작한 뒤, 기존 access token이 만료될 때까지 old public key를 남깁니다.

import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(
  new URL("https://auth.example.com/.well-known/jwks.json")
);

export async function verifyWithRotatingKeys(token: string) {
  return jwtVerify(token, JWKS, {
    issuer: "https://auth.example.com",
    audience: "claudecodelab-api",
    algorithms: ["RS256", "ES256"],
  });
}
{
  "rotationPlan": {
    "step1": "새 키를 생성하고 JWKS에 공개한다",
    "step2": "새 kid로 새 token 서명을 시작한다",
    "step3": "기존 access token 만료까지 old public key를 유지한다",
    "step4": "로그를 확인한 뒤 old key를 제거한다"
  }
}

유스케이스와 안전한 prompt

flowchart LR
  Login["로그인"] --> Access["짧은 access token"]
  Login --> Refresh["HttpOnly refresh cookie"]
  Access --> API["API가 iss/aud/exp/jti 검증"]
  Refresh --> Rotate["refresh 때 rotation"]
  Rotate --> Store["DB/Redis에 hash와 sid 저장"]
  Store --> Reuse["reuse 감지 시 token family 폐기"]

유스케이스 1은 SaaS 관리자 화면입니다. tenantId를 claim에 넣을 수 있지만, DB query에도 tenant 조건을 반드시 넣어야 합니다. 관리자 권한, 요금제, 계정 정지 상태는 중요한 작업 전에 서버에서 다시 확인합니다.

유스케이스 2는 유료 콘텐츠나 강의 사이트입니다. access token은 짧게 두고 refresh token으로 조용히 갱신합니다. 광고, Analytics, 구매 CTA가 함께 있다면웹 보안 헤더와 Cookie 동의 설계까지 같이 봐야 합니다.

유스케이스 3은 모바일 또는 데스크톱 앱입니다. Cookie 대신 OS 보안 저장소를 쓰는 경우가 많습니다. 기기 분실에 대비해sid로 개별 session을 폐기하고, refresh token 재사용은 보안 이벤트로 기록합니다.

유스케이스 4는 마이크로서비스입니다. 대칭키 secret을 모든 서비스에 배포하지 말고 공개키 검증, gateway 검증, token exchange를 검토하세요. 각 서비스는 반드시aud를 확인해야 합니다.

Claude Code 구현 prompt는 다음처럼 구체화합니다.

이 저장소에 JWT 인증을 설계하고 구현해 주세요.
수정 전에 표로 조사하세요:
- framework, user model, session/cookie 코드, auth middleware
- 기존 authorization check, CSRF, CSP, rate limit
- 현재 token 저장 위치와 XSS/CSRF 위험

구현 규칙:
- jose를 사용하고 jsonwebtoken으로 되돌리지 않는다.
- access token 15분, refresh token 7일.
- iss, aud, sub, exp, iat, jti, sid를 검증한다.
- refresh token은 hash만 DB/Redis에 저장한다.
- rotation을 구현하고 reuse 시 같은 sid family를 폐기한다.
- secret, .env, production token을 출력하지 않는다.
- 마지막에 test 또는 curl 검증 증거를 남긴다.

실패 사례, 검증, CTA

실패 사례는 반복됩니다. 알고리즘을 고정하지 않는다. payload에 개인정보를 넣는다. audiss를 검증하지 않는다. refresh token을 재사용 가능하게 둔다. logout 때 브라우저 Cookie만 지운다. 키 교체 시 old key를 즉시 삭제한다. Claude Code에 실제 secret을 붙여 넣는다. 각각 알고리즘 allowlist, 최소 claim, rotation, sid 폐기, JWKS 겹침 기간, secret 마스킹으로 막아야 합니다.

curl -i -X POST https://example.com/api/auth/login \
  -H "content-type: application/json" \
  -d '{"email":"demo@example.com","password":"correct horse"}'

npm test -- --runInBand auth

만료 token, 변조된 서명, 잘못된 audience, 폐기된jti, 재사용된 refresh token, logout, 비밀번호 변경, 계정 정지를 테스트하세요. refresh Cookie가HttpOnly, Secure, 적절한SameSite, 좁은path를 갖는지도 확인합니다.

개인 개발자는무료 Claude Code cheatsheet로 검증 습관을 먼저 고정할 수 있습니다. prompt와 template이 필요하면ClaudeCodeLab products를 보세요. 팀에서 JWT, RBAC, Cookie, 감사 로그, CI gate를 함께 정리하려면Claude Code training and consultation이 현실적입니다.

이 글의 예제를 실제로 점검하면서 가장 도움이 된 것은 서명 코드보다 claim 표를 먼저 작성한 일이었습니다. 그 과정에서aud 검증 누락, 기본 tsx 실행 경로의 top-level await 문제, refresh token 평문 저장 위험을 미리 발견했습니다. JWT 인증은 token을 만들면 끝이 아니라, claim, 검증, 저장, rotation, 폐기, 키 관리까지 하나의 리뷰 가능한 흐름으로 만드는 작업입니다.

#Claude Code #JWT #인증 #보안 #Node.js
무료

무료 PDF: Claude Code 치트시트

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

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

Masa

작성자 소개

Masa

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