bcrypt, argon2 같은 라이브러리는 순수 JavaScript가 아니다. 속도가 중요한 부분을 C/C++로 짜서 미리 컴파일해둔 실행 파일을 Node가 불러다 쓰는 구조인데(이를 네이티브 바인딩이라고 한다), Cloudflare Workers는 Node가 아니라 V8 isloate라는 JS 전용 샌드박스에서 돌기 때문에 그 실행 파일을 못 읽는다.
Node.js 전용 crypto 모듈도 Workers에서는 일부만 지원된다.
따라서 Web Crypto API(crypto.subtle)만 쓰기로 했다.
브라우저 표준 API인데 Node.js 18+와 Workers 양쪽 다 포함돼있다.
또한 bcrypt를 못 쓰니 비밀번호 해싱은 PBKDF2로 구현했다. Web Crypto가 기본 제공하는 KDF(Key Derivation Function)가 PBKDF2다.
Cloudflare Workers의 Web Crypto PBKDF2는 반복 횟수 상한이 100,000이기에 그 값에 맞추어 반복 횟수를 설정했다.
그 이상을 넘기면 Workers에서 에러가 발생한다. 관리자 계정 한두 개 + salt + 짧지 않은 비밀번호라는 조건에서 감당 가능하다고 판단했다.
라우트 구조가 곧 보안 경계
인증 로직을 짜기 전에 폴더 구조부터 정했다. App Router에서는 파일을 어느 폴더에 두느냐가 그대로 보호 여부가 되게 만들 수 있다.
src/app/├── (site)/ ← 공개. 행사 소개, 공지 열람 등 방문자용 페이지│ ├── page.tsx│ ├── community/notice/...│ └── ...│└── admin/ ├── page.tsx ← 게이트 밖. /admin 접근 시 /admin/notices로 redirect만 ├── login/page.tsx ← 게이트 밖. 로그인 폼 │ └── (protected)/ ← 게이트 안 ├── layout.tsx ← 여기서 세션을 확인. 통과 X시 /admin/login으로 ├── notices/page.tsx ├── notice-popups/page.tsx └── stats/page.tsx
(site)와 (protected)는 소괄호로 감싼 **라우트 그룹(Route Groups)**이라 URL에는 안 나온다(/admin/(protected)/notices가 아니라 /admin/notices로 나옴). URL 경로는 그대로 두면서 레이아웃만 다르게 씌우는 장치다.
Next.js 공식 문서는 라우트 그룹의 용도로 세 가지를 들고있다. 팀/관심사/기능별로 라우트 묶기, 여러 개의 root layout 두기, 그리고 같은 폴더 계층에 있는 라우트 중 일부에만 공통 layout.tsx를 씌우고 나머지는 빼는 것.
마지막 용도가 이 프로젝트에서 필요한 목적이다.
/admin 아래에는 페이지가 다섯 개가 존재한다.
/admin → /admin/notices로 리다이렉트 (레이아웃 필요 없음)/admin/login → 로그인 폼 (로그인 기능, 인증 X)/admin/notices → 공지 관리 (인증 필요)/admin/notice-popups → 팝업 관리 (인증 필요)/admin/stats → 통계 (인증 필요)
layout.tsx는 자기 폴더 아래 모든 페이지를 감싸 적용한다.
그래서 admin/layout.tsx에 세션 검사를 넣으면 로그인 페이지까지 같이 걸리고, 로그인하러 들어온 사람이 로그인 페이지 접근을 거부당하는 모순이 생긴다.
그렇다고 인증이 필요한 세 개만 admin/protected/ 같은 폴더로 묶으면 URL이 /admin/protected/notices로 바뀌어버린다.
(protected)처럼 소괄호로 묶으면 URL은 /admin/notices 그대로 두면서, (protected)/layout.tsx가 그 안의 세 페이지만 감싼다. 밖에 있는 login과 page.tsx는 이 레이아웃과 무관해진다.
주의할 점도 문서에 있다. 서로 다른 그룹의 라우트가 같은 URL로 겹치면 에러가 난다((a)/about/page.tsx와 (b)/about/page.tsx는 둘 다 /about이라 충돌). 그룹은 어디까지나 폴더 정리 규칙일 뿐이지, URL을 새로 파는게 아니라는 점만 기억하자.
핵심은 admin/(protected)/layout.tsx 한 곳이 그 그룹 전체의 문지기라는 것이다. 나중에 관리자 페이지를 추가할 때 (protected)/ 안에 파일만 만들면 인증이 자동으로 걸린다. 인증 코드를 페이지마다 붙여넣지 않아도 된다.
middleware 쓰면 되잖아?
Next.js에는 파일 하나로 모든 요청을 가로채서 인증을 거는 방식이 있다. 흔한 패턴인데 이 프로젝트에는 그 파일이 없다.
대신, 위에서 본 것처럼 (protected)/layout.tsx(페이지)와 requireAdmin()(API 라우트) 두 지점에서 직접 확인한다.
참고로, 이 프로젝트가 사용하는 Next.js 16부터 middleware가 proxy로 이름이 바뀌었다.middleware.ts 컨벤션은 deprecated 되고 proxy.ts로 넘어갔다. 기능은 그대로인데, 공식 설명이 "이름에 목적을 더 잘 비추기 위해" 바꿨다고 한다. 이건 요청 프록시 계층이지 인증 계층이 아니라는 걸 이름으로 못박은 것이다.
문서도 대놓고 이렇게 적혀 있다.
While Proxy can be helpful for optimistic checks such as permission-based redirects, it should not be used as a full session management or authorization solution.
Proxy는 권한 기반 리다이렉트 같은 낙관적(optimistic) 체크에는 유용할 수 있지만, 완전한 세션 관리나 인가(authorization) 솔루션으로 쓰여서는 안 된다.
deriveBits가 그 비밀번호에 salt를 섞어 SHA-256을 10만 번 돌리고, 결과에서 256비트를 잘라 돌려준다.
이 256비트가 "이 비밀번호의 지문" 역할을 한다. 같은 비밀번호 + 같은 salt면 항상 같은 값이 나오고, salt가 바뀌면 완전히 달라진다.
저장할 때 — 관리자 계정을 처음 만들 때 한 번 쓴다.
export async function hashPassword(password: string): Promise<string> { const salt = crypto.getRandomValues(new Uint8Array(16)); // 16바이트 랜덤 salt const hash = await pbkdf2(password, salt); return `${toHex(salt)}:${toHex(hash)}`; // "salt(hex):hash(hex)"}
결과는 "3f9a...(32자) : 8c1b...(64자)" 형태의 문자열 하나다. salt와 해시를 콜론으로 이어 붙여서 D1(Cloudflare의 SQLite) admin 테이블의 password_hash 컬럼에 그대로 넣는다. 검증할 때 salt가 다시 필요하니 함께 저장한다.
검증할 때 — 로그인 요청마다 한 번.
export async function verifyPassword(password: string, stored: string): Promise<boolean> { const [saltHex, hashHex] = stored.split(":"); if (!saltHex || !hashHex) return false; const hash = await pbkdf2(password, fromHex(saltHex)); // 저장된 salt로 다시 계산 return timingSafeEqual(toHex(hash), hashHex);}
저장해둔 salt를 꺼내서, 사용자가 방금 입력한 비밀번호에 붙여 PBKDF2를 다시 돌린다. 그 결과가 저장된 해시와 같으면 비밀번호가 맞은 것이다.
timing-safe 비교
해시 비교를 왜 ===로 안 하고 timingSafeEqual이라는 함수로 할까.
function timingSafeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let result = 0; for (let i = 0; i < a.length; i++) { result |= a.charCodeAt(i) ^ b.charCodeAt(i); // 다르면 비트가 켜짐 } return result === 0; // 끝까지 다 돈 뒤에 판정}
공격자가 응답 시간을 정밀하게 측정하면 "앞 몇 글자가 맞았는지"를 추론할 수 있다. 이걸 한 글자씩 좁혀가면 이론상 해시 전체를 알아낼 수 있다. 이런 걸 타이밍 공격이라고 한다.
timingSafeEqual은 어디서 틀리든 상관없이 항상 문자열 전체를 끝까지 비교한 뒤에 결과를 낸다. 그래서 비교에 걸리는 시간이 입력과 무관하게 일정하다. 이 프로젝트에선 Node.js 기본 내장 crypto.timingSafeEqual이 있지만 Workers 호환을 위해 직접 구현했다.
비밀키가 없으면 500으로 즉시 중단한다. "설정이 빠졌으면 통과시키지 말고 실패시킨다(fail closed)"는 원칙에 따른다.
아이디가 있든 없든 같은 메시지, 같은 상태코드(401)를 준다."아이디가 존재하지 않습니다"처럼 나눠서 응답하면, 공격자가 유효한 아이디 목록을 수집할 수 있다. 뭉뚱그려서 추측조차 힘들게 해야한다.
DB 조회에 noStore: true를 붙인다. 이 프로젝트의 d1Query는 기본적으로 SELECT 결과를 60초 캐시하는데, 로그인 조회가 캐시되면 곤란하므로 매번 강제로 최신화한다.
위 방법들론 완벽하진 않다. 아이디가 없으면 verifyPassword(PBKDF2 10만 회)를 건너뛰기 때문에 응답이 눈에 띄게 빠르다. 앞서 말한 타이밍 공격과 같은 원리로, 응답 속도만 재도 "이 아이디가 존재하는지"를 추측할 수 있다. 제대로 막으려면 아이디가 없을 때도 더미 해시를 한 번 돌려서 응답 시간을 맞춰줘야 한다. 관리자 아이디가 고정된 소규모 운영이라 지금은 감수하고 있는 부분이다.
로그인 폼(LoginForm)은 "use client"지만 하는 일은 fetch 호출과 에러 표시, 성공 시 router.push + router.refresh()뿐이다. 검증 로직은 한 줄도 클라이언트에 없다. router.refresh()가 중요한데, 이게 있어야 서버 컴포넌트 트리가 새 쿠키를 들고 다시 실행된다. 로그아웃도 대칭으로 두 상태 모두 delete 후 router.refresh()를 불러 화면을 로그인 상태에 맞춘다.
cookieStore.set(SESSION_COOKIE_NAME, token, { httpOnly: true, // JS(document.cookie)에서 접근 불가 → XSS로 토큰 탈취 방지 secure: isHttps, // HTTPS 요청일 때만 전송 sameSite: "lax", // 다른 사이트가 유발한 요청엔 원칙적으로 안 실림 (CSRF 완화) path: "/", maxAge: SESSION_COOKIE_MAX_AGE, // 7일});
secure를 true로 고정하지 않고 isHttps라는 변수로 준 이유가 있다. 운영은 Cloudflare 프록시 뒤에 있어서 Worker가 받는 request.url의 프로토콜이 클라이언트의 실제 연결(HTTPS)과 다를 수 있다. 그래서 x-forwarded-proto 헤더(프록시가 "원래 클라이언트는 http/https 중 뭘로 붙었다"고 뒷단 서버에 알려주는 표준 헤더)를 먼저 보고 HTTPS 여부를 판단한다. 이렇게 안 하면 로컬 http://localhost에서 secure 쿠키가 아예 안 붙어 로그인이 안 되거나, 반대로 운영에서 프로토콜을 잘못 읽는 문제가 생긴다.
sameSite는 strict가 아니라 lax다. strict면 외부 링크나 북마크로 /admin에 들어왔을 때 첫 요청에 쿠키가 안 실려서 로그인 화면으로 튕긴다. 상태를 바꾸는 동작은 전부 fetch(POST + JSON)로만 이뤄지고 폼 전송이 없어서, lax로도 폼 기반 CSRF 위험은 낮다고 봤다.
이 방식은 JWT를 안 쓰고 직접 만든 축소판이다. JWT는 header.payload.signature 3토막 속에 알고리즘 정보 등이 들어가지만, 여기선 알고리즘이 HMAC-SHA256으로 고정이라 header를 생략하고 payload.signature 2토막으로 줄였다.
서버 비밀키는 ADMIN_SESSION_SECRET 환경변수로 주입한다. 이게 유출되면 누구나 admin 출입증을 찍어낼 수 있으므로, 코드에 하드코딩하지 않고 Workers Secrets와 .dev.vars로만 관리한다.
세션 검증 — verifySessionToken
export async function verifySessionToken( token: string, secret: string,): Promise<SessionPayload | null> { const [payloadB64, sigB64] = token.split("."); if (!payloadB64 || !sigB64) return null; const key = await hmacKey(secret); const valid = await crypto.subtle.verify( "HMAC", key, base64UrlDecodeToBuffer(sigB64), new TextEncoder().encode(payloadB64), ); if (!valid) return null; // 서명 불일치 = 위조됐거나 다른 키로 만든 것 try { const payload = JSON.parse(base64UrlDecode(payloadB64)) as SessionPayload; if (payload.exp < Date.now()) return null; // 만료됨 return payload; } catch { return null; }}
세션토큰 검증과정이다. 순서대로 보자.
토큰을 . 기준으로 자른다 → payloadB64(내용), sigB64(서명) 두 조각. 둘 중 하나라도 없으면 형식이 깨진 거라 바로 null로 리턴 때린다.
서명 키 생성 — 서버 비밀키(secret)로 HMAC 검증용 키 객체를 준비한다.
서명 대조 — crypto.subtle.verify가 payloadB64를 서버 비밀키로 다시 HMAC 해서, 토큰에 붙어온 sigB64와 동일한지 대조한다. 동일하지않으면 위조됐거나 다른 키로 만든 토큰이므로 즉시 null처리한다.
payload를 읽는다 — 서명이 통과한 뒤에 payloadB64를 디코딩·JSON 파싱한다. (이 과정에서 깨진 JSON이면 catch로 떨어져 null로 방출)
만료를 확인한다 — exp가 현재 시각보다 이전이면 만료된 토큰이라 null 처리한다.
SubtleCrypto.verify() 에 대해 짧게 보고 가자
이 메서드는 디지털 서명을 검증한다.
인자로 알고리즘별 매개변수, 서명을 검증할 키, 서명, 그리고 원본 데이터를 받는다. 서명이 유효한지를 나타내는 불리언 값으로 이행되는 Promise를 반환한다.
verify(algorithm, key, signature, data)
algorithm — 사용할 알고리즘을 지정하는 문자열 또는 객체. 여기 넘기는 값은 대응하는 sign() 호출에 넘긴 값과 일치해야 한다. HMAC을 쓰려면 문자열 "HMAC" 또는 { "name": "HMAC" }를 넘긴다.
key — 서명 검증에 쓸 CryptoKey. 대칭 알고리즘에서는 비밀키, 공개키 방식에서는 공개키에 해당하는 키값이다.
signature — 검증할 서명이 담긴 ArrayBuffer.
data — 서명을 검증할 대상 데이터가 담긴 ArrayBuffer.
반환값 — boolean으로 이행되는 Promise. 서명이 유효하면 true, 아니면 false.
redirect(페이지)와 401 JSON(API)의 차이만 있고, 안쪽은 둘 다 getAdminSession → verifySessionToken → HMAC 검증 한 줄이다.
전체 흐름
[로그인] POST /api/admin/auth/login │ 아이디로 D1에서 admin 행 조회 │ verifyPassword(입력 비번, 저장된 "salt:hash") ← PBKDF2 10만 회 (여기서만) │ 일치하면 createSessionToken({ sub, username }) ← HMAC 서명 1회 └─ Set-Cookie: admin_session=payload.signature (HttpOnly, SameSite=Lax, 7일)[이후 모든 관리자 요청] GET /admin/notices 등 │ layout.tsx → getAdminSession() │ 쿠키에서 토큰 꺼냄 → verifySessionToken() ← HMAC 검증 1회 (DB 조회 없음) ├─ 유효 → 페이지 렌더 └─ 무효/없음 → redirect("/admin/login")[로그아웃] POST /api/admin/auth/logout └─ cookieStore.delete("admin_session") ← 이게 전부. 서버엔 지울 상태가 없음
무거운 연산(PBKDF2)은 로그인 순간에 격리돼 있고, 자주 실행되는 경로(매 페이지)에는 가벼운 HMAC 검증만 남는다. 무료 플랜 CPU 한도에 데였던 이 프로젝트에서, 만약 매 요청마다 PBKDF2를 10만 번씩 돌렸다면 관리자 페이지는 열 때마다 CPU 시간 초과로 죽었을 것이다. 실제로 그 사건 원인을 분석할 때 "비밀번호 해싱(PBKDF2)이 CPU를 먹는 것 아니냐"는 의심이 나왔는데, 로그인 경로에서만 돌고 일반 요청 경로와 무관하다는 걸 확인하고 원인에서 제외했다.
그동안 쓰던 방식과 비교 — DB 세션, JWT
지금까지 로그인은 대부분 둘 중 하나로 붙였다. 서버에 세션을 저장하는 DB 세션, 아니면 라이브러리로 발급하는 JWT. 이번 방식은 사실상 JWT를 라이브러리 없이 축소해 만든 것이라, 셋을 나란히 두면 차이가 분명해진다.
DB 세션 (전통적인 세션 방식)
쿠키에는 의미 없는 랜덤 문자열(세션 ID)만 넣는다. 진짜 정보(누가, 언제까지, 권한)는 서버 저장소(DB, Redis)에 두고, 요청이 올 때마다 세션 ID로 그 저장소를 조회한다.
강제 로그아웃이 쉽다. 저장소에서 레코드만 지우면 그 세션은 그 즉시 죽는다. "다른 기기에서 로그아웃", "비밀번호 변경 시 전체 세션 종료" 같은 플로우가 빠릿하고 자연스러워진다.
세션 내용을 언제든 갱신할 수 있다. 권한이 바뀔 때, DB에서 값만 고치면 다음 요청부터 반영된다.
대신 매 요청마다 저장소 I/O가 든다. 서버가 여러 대면 저장소를 공유해야 하고(그래서 보통 Redis를 둔다), 그 저장소가 죽으면 로그인도 같이 죽는다.
JWT (라이브러리로 발급)
header.payload.signature 3토막 문자열. jsonwebtoken, jose 같은 라이브러리로 만든다. 서버는 아무것도 저장하지 않고, 토큰에 적힌 내용과 서명만 보고 판단한다.
저장소 조회가 없다. 서명 검증 한 번이면 끝이라 서버를 몇 대로 늘려도 잘 돌아간다.
표준(RFC 7519)이고 생태계가 크다.iss / aud / exp 같은 표준 클레임이 정해져 있고, OAuth·OIDC를 비롯해 남이 발급한 토큰을 검증하는 시나리오까지 라이브러리가 다 커버한다.
대신 즉시 무효화가 어렵다. 한 번 발급하면 만료 전까지 유효해서, 강제 로그아웃을 하려면 결국 서버에서 블랙리스트(=작은 DB 세션)를 또 따로 둬야 한다.
alg: none 우회처럼 JWT 특유의 함정이 있어서, 라이브러리를 최신으로 유지하고 알고리즘을 고정하는 등의 주의가 필요하다.
이번 프로젝트 사용 방식
JWT에서 군더더기를 걷어낸 형태다. header를 없애고 알고리즘을 HMAC-SHA256으로 코드에 고정한 뒤, payload.signature 2토막만 남겼다. 누차 강조하지만, 사용자가 매우 적고 접근이 제한된 관리자 로그인이라 가능한 방식이다.
무상태라는 점은 JWT와 똑같다. 저장소 조회 없음, 수평 확장 자유, 즉시 무효화 불가, 페이로드는 그냥 읽힌다. 장단점은 JWT와 다를바 없다.
의존성이 0개다.jsonwebtoken은 Node crypto에 묶여 Workers에서 안 돌아가고, jose는 Workers에서 돌지만 이 프로젝트가 쓰는 기능은 "HMAC 서명/검증" 하나뿐이라 WEB crypto만 쓰는 여기선 표준 JWT 스펙 대부분이 오버스펙이었다.
alg: none 같은 함정이 원천 봉쇄된다. JWT는 토큰 헤더에 "이 토큰은 무슨 알고리즘으로 검증해"라는 alg 값이 들어있는데, 공격자가 이걸 none으로 바꾸고 서명을 떼버리면 허술한 검증기는 "서명 검사 안 함"으로 통과시켜 버린다. 이번 방식은 토큰에서 알고리즘을 읽지 않는다. 검증 코드가 항상 HMAC-SHA256으로 고정돼 있어서, 공격자가 만질 alg 값 자체가 없다. 대신 표준이 아니라 이 토큰을 다른 시스템이 검증할 수는 없다.
정리
DB 세션
JWT
이번 방식
쿠키에 담기는 것
세션 ID (랜덤)
페이로드 + 서명
페이로드 + 서명
서버 저장소
필요 (매 요청 조회)
불필요
불필요
요청당 비용
저장소 I/O 1회
서명 검증 1회
HMAC 검증 1회
즉시 로그아웃
쉬움 (레코드 삭제)
어려움 (블랙리스트 필요)
어려움 (동일)
서버 수평 확장
저장소 공유 필요
바로 됨
바로 됨
페이로드 비밀 유지
됨 (서버에만)
안 됨 (base64, 누구나 읽음)
안 됨 (동일)
의존성
세션 미들웨어 + 저장소
JWT 라이브러리
없음 (Web Crypto)
표준 · 생태계
프레임워크마다 다름
RFC 7519, OIDC 등
없음 (자체 포맷)
각각 어디서 쓰는게 좋을까
즉시 무효화가 중요하면 DB 세션. 금융 서비스, 다중 기기 세션 관리, 관리자가 사용자를 강제 로그아웃시켜야 하는 경우.
여러 서비스가 토큰을 공유하거나 외부 인증(OAuth/OIDC)과 엮이면 JWT. 이때는 직접 만들지 말고 검증된 라이브러리를 쓰는 게 맞다.
단일 앱 + 관리자 소수 + 무상태를 원하면 이번처럼 축소한 서명 토큰으로 충분하다.
실무에서 흔한 절충안은 하이브리드 방식이다. 짧은 수명(15분)의 JWT를 access token으로 쓰고, 그걸 갱신하는 refresh token은 DB 세션으로 관리한다. 무상태의 이점을 살리면서 무효화 구멍도 메우는 방식인데, 이 프로젝트는 거기까지 갈 규모가 아니라서 7일짜리 단일 토큰으로 멈췄다.
토큰 강제 무효화 없음. 상태 비저장 방식이라 발급된 토큰은 7일 만료 전까지 유효하다. 비밀번호를 바꿔도 기존 세션은 안 끊긴다. 로그아웃도 서버에서 하는 일은 쿠키 삭제뿐이라, 그 전에 토큰이 복사된 적이 있으면 만료까지 계속 유효하다. (해결책은 비밀키 교체를 통한 전체 세션 무효화, 또는 서버에 무효화 목록 두기 등이 있다)
refresh token / 세션 회전 없음. 7일짜리 단일 토큰이다.
로그인 시도 rate limiting은 앱 레벨에 없다. 무차별 대입 방어는 Cloudflare WAF 쪽에서 다룰 사안으로 미뤄뒀다. (같은 글의 존 요금제 부분에 정리)
끝으로..
이 간단한 admin 로그인 하나에도 서로 다른 목적의 암호 도구 두 개가 들어간다.
비밀번호 저장·검증에는 PBKDF2 — salt로 레인보우 테이블을 막고, 반복 10만 회로 무차별 대입을 방대한 양의 쓴맛으로 바꾼다. 로그인할 때만 돈다.
세션 출입증에는 HMAC — 서버 비밀키로 서명해서 위조를 막는다. 내용을 숨기는 게 아닌 다른 엄한 손을 안 탔다는 걸 보증하는 역할이다. 매 요청마다 돌지만 가볍다.
이 둘을 나누는 판단 기준은 실행 빈도이다. 자주 실행되는 곳(세션 검증)은 가볍게, 드물게 실행되는 곳(비밀번호 검증)에만 무거운 걸 쓴다. Cloudflare Workers처럼 요청당 CPU를 신경써야하는 환경에서는 이 분리가 특히 중요했다.
세션 방식 자체는 그동안 쓰던 JWT에서 군더더기만 걷어낸 것이고, DB 세션과 비교하면 "즉시 무효화를 포기하는 대신 저장소를 없앤" 맞바꿈이다. 규모가 커지면 이 선택은 다시 바꿔야겠지만, 관리자 한두 명짜리 admin에는 이 정도가 적당했다.