Session
서버 저장소에 로그인 상태를 둔다. 즉시 로그아웃, 계정 정지, 디바이스 제어가 쉽다.
내부 어드민, 단일 서비스, 보안 이벤트 대응이 중요한 서비스분류: Layer 1 - 백엔드 기초
첫 회독은 인증과 인가를 분리하고, 인가를 정책 입력과 결정으로 읽는 데만 집중한다. 아래 순서대로 읽으면 된다.
1 → 2 → 3-1 → 3-2 → 7-1 → 8-1 → 8-2 → 17의 "첫 회독 완료"2.5, 3-3, 4~6, 7-2~7-4, 8-3, 9~16, 17의 "선택 심화 복습", 18이 경로에서는 “누구인지 확인하는 일”과 “무엇을 허용할지 판단하는 일”을 구분하고, 인가 요청을 Subject → Action → Resource → Policy → Decision으로 분해할 수 있으면 충분하다. claim 전체 검증, Session/JWT 선택, Refresh Token Rotation, OAuth/OIDC, RBAC/ABAC/PBAC, JWT 키 운영은 두 번째 회독에서 다룬다. 건너뛴 절은 삭제 가능한 부록이 아니라 실제 구현과 운영 판단을 위한 선택 심화 자산이다.
Authentication(인증, AuthN)은 “요청자가 누구인가?”를 확인하는 과정이다. Authorization(인가, AuthZ)은 “확인된 요청자가 이 행동을 해도 되는가?”를 판단하는 과정이다.
둘은 항상 붙어 다니지만 같은 일이 아니다. 로그인 성공은 인증이고, 로그인한 사용자가 DELETE /orders/1을 호출할 수 있는지 판단하는 것은 인가다.
퀴즈
힌트: 디버깅 시작점이 다르다.
전자는 인증 실패라 401, 후자는 인가 실패라 403이다. 401은 토큰 발급·만료·서명·쿠키 전달을 먼저 보고, 403은 role·permission·policy·resource scope를 먼저 본다.
사용자 기능, 관리자 기능, 사내 API, AWS IAM, Kubernetes RBAC, 데이터베이스 권한은 모두 같은 질문 위에 서 있다.
인증과 인가를 섞으면 실패 원인을 잘못 본다. 만료된 토큰 때문에 401이 난 요청에 role을 추가해도 해결되지 않는다. 반대로 토큰은 정상인데 403이 난 요청에 재로그인을 반복해도 권한은 생기지 않는다. 이 문서의 목표는 특정 프레임워크 설정을 외우는 것이 아니라, 어떤 시스템을 만나도 identity -> token/session -> policy -> decision 흐름으로 분해할 수 있게 만드는 것이다.
초기 웹은 사용자명과 패스워드를 매 요청에 보내거나, 서버 메모리의 세션 ID 하나로 로그인 상태와 권한을 함께 처리했다. 작은 서비스에서는 동작했지만 규모가 커지면서 두 문제가 동시에 드러났다.
인증 축의 문제: 패스워드는 사용자가 재사용하고 유출된다.
패스워드는 사람이 만들고 기억한다. 그래서 여러 서비스에 재사용되고, 피싱·인포스틸러·credential stuffing 공격에 취약하다. Verizon 2025 DBIR 기준으로 전체 breach의 22%는 탈취된 자격 증명을 초기 침투 벡터로 사용했고, SSO 인증 시도 로그의 중위값 19%가 credential stuffing 트래픽으로 관측됐으며, 인포스틸러 감염 장비의 사용자 패스워드 중 서비스별로 고유한 것은 49%뿐이었다. 이 문제는 “패스워드를 더 자주 입력하게 한다”로 풀리지 않았다.
인증 체계는 다음 방향으로 나뉘었다.
인가 축의 문제: 사용자별 ACL은 운영 규모에서 폭발한다.
ACL(Access Control List)은 “이 사용자에게 이 리소스 권한을 준다”는 직접 목록이다. 사용자 1만 명과 리소스 1만 개를 직접 연결하면 최악의 경우 1억 개 관계를 관리해야 한다. 조직 개편, 팀 이동, 임시 권한, 퇴사 처리가 쌓이면 누락과 과다 권한이 동시에 생긴다.
RBAC(Role-Based Access Control)는 이 직접 연결을 Subject -> Role -> Permission의 간접 참조로 바꾼다. 사람에게 권한을 하나씩 붙이는 대신 역할에 권한을 붙이고, 사람은 역할에 배정한다. 그래서 사용자 수가 늘어도 권한 설계의 중심은 “역할과 정책”으로 이동한다. NIST의 Ferraiolo-Kuhn 모델이 1992년에 DAC/MAC의 대안으로 RBAC를 정리하고, 이후 2004년 ANSI INCITS 359로 표준화된 이유도 이 운영 문제를 줄이기 위해서다.
DAC(Discretionary Access Control)는 리소스 소유자가 접근 권한을 나눠주는 모델이고, MAC(Mandatory Access Control)은 시스템의 중앙 정책과 등급이 접근을 강제하는 모델이다. L1에서는 이름만 외우기보다, RBAC가 사용자-리소스 직접 연결을 역할이라는 중간 계층으로 줄였다는 점을 잡으면 충분하다.
이 토픽이 사라지면 다음 판단이 전부 흐려진다.
인증/인가 문서를 읽을 때 가장 먼저 막히는 단어가 identity, principal, subject다. 셋은 비슷하지만 쓰임이 조금 다르다.
| 용어 | 의미 | 예시 |
|---|---|---|
| Identity | 시스템이 알고 있는 신원 레코드 | 사용자 계정, 서비스 계정, 조직 멤버 |
| Principal | 요청 시점에 행동하는 주체 | 로그인한 사용자, IAM Role, Kubernetes ServiceAccount |
| Subject | 토큰이나 정책 평가 안에서 식별되는 주체 | JWT sub, OIDC subject, policy input의 user |
실무에서는 “누가 요청했는가?”를 먼저 고정해야 한다. 브라우저에서 버튼을 누른 사람, 백엔드가 대신 호출한 서비스 계정, 임시로 assume한 IAM Role은 모두 다른 principal이다. 같은 사람이 시작한 작업이어도 실제 API 호출 principal이 다르면 인가 결과도 달라진다.
인가는 항상 주체만으로 끝나지 않는다. 최소한 다음 두 축이 더 필요하다.
그래서 인가 판단은 보통 (Subject, Action, Resource) 조합으로 쓴다.
subject = user:aliceaction = order:deleteresource = order:123
policy decision = allow or deny이 모델을 잡으면 프레임워크가 달라져도 같은 방식으로 볼 수 있다.
| 시스템 | Subject | Action | Resource |
|---|---|---|---|
| NestJS API | req.user.sub | DELETE | /orders/:id |
| AWS IAM | IAM User/Role ARN | s3:GetObject | arn:aws:s3:::bucket/key |
| Kubernetes | User/Group/ServiceAccount | get, create, delete | pods, secrets, namespace |
| PostgreSQL | DB role | SELECT, UPDATE | table, schema |
토큰의 claim은 “이 사용자는 admin이다”, “이 토큰은 이 API용이다”, “이 토큰은 이 시각까지 유효하다” 같은 주장이다. 중요한 점은 claim이 토큰에 들어 있다고 해서 자동으로 믿어도 되는 것이 아니라는 점이다. 서버는 서명과 필수 claim을 검증한 뒤에만 그 주장을 신뢰해야 한다.
JWT에서 자주 보는 claim은 다음과 같다.
| Claim | 뜻 | 검증하지 않으면 생기는 문제 |
|---|---|---|
sub | subject, 사용자나 서비스의 안정적인 ID | 이메일 변경이나 표시명 변조에 취약 |
iss | issuer, 토큰 발급자 | 다른 서비스가 발급한 토큰이 통과 |
aud | audience, 토큰 대상 API | A 서비스용 토큰으로 B 서비스 호출 |
exp | 만료 시각 | 탈취 토큰이 계속 재사용 |
nbf | not before, 사용 시작 시각 | 아직 유효하지 않은 토큰이 통과 |
iat | 발급 시각 | 비정상적으로 오래된 토큰 탐지 어려움 |
jti | JWT ID, 토큰 고유 ID | 로그아웃·재사용 감지·블랙리스트 추적 어려움 |
scope | OAuth 권한 범위 | 위임받지 않은 외부 리소스 접근 |
role 또는 permissions | 애플리케이션 인가 힌트 | 과도한 권한이 토큰에 오래 남음 |
주의: JWT Payload는 암호화가 아니다. Base64URL로 인코딩되어 있을 뿐이라 누구나 디코딩할 수 있다. Signature는 “위조 방지”를 위한 것이지 “내용 숨김”을 위한 것이 아니다. 비밀번호, 주민등록번호, 카드번호, 내부 운영 토큰 같은 민감 정보는 Payload에 넣지 않는다.
Session은 서버가 로그인 상태를 저장하고, 클라이언트에는 세션 ID만 전달하는 방식이다. 브라우저는 보통 쿠키로 세션 ID를 보내고, 서버는 Redis나 DB에서 세션을 조회한다.
1. 사용자가 로그인한다.2. 서버가 session_id = abc123을 만들고 Redis에 저장한다.3. 브라우저는 Cookie: sid=abc123을 이후 요청마다 보낸다.4. 서버는 sid로 세션 저장소를 조회해 사용자를 식별한다.장점은 서버가 상태를 쥐고 있다는 점이다. 계정 정지, 로그아웃, 디바이스 강제 종료를 즉시 반영하기 쉽다. 단점은 모든 API 서버가 같은 세션 저장소를 조회해야 하므로 공유 저장소와 장애 처리가 필요하다.
세션 쿠키를 쓰면 CSRF(Cross-Site Request Forgery)도 함께 봐야 한다. 브라우저가 쿠키를 자동으로 붙이기 때문에, 공격 사이트가 사용자의 브라우저를 통해 요청을 보내는 문제가 생길 수 있다. SameSite, CSRF token, Origin/Referer 검증이 세션 기반 인증에서 중요한 이유다.
JWT(JSON Web Token)는 Header, Payload, Signature로 구성된 서명된 토큰이다. API 서버는 Signature와 claim을 검증해 토큰이 진짜인지 판단한다. 매 요청마다 인증 서버나 DB를 조회하지 않아도 된다는 점이 핵심이다.
sequenceDiagram participant C as Client participant A as Auth Server participant API as API Server C->>A: 로그인 요청 A-->>C: Header + Payload + Signature로 만든 JWT 발급 C->>API: Authorization: Bearer JWT API->>API: 서명, iss, aud, exp, nbf 검증 API-->>C: 검증된 subject 기반으로 응답
JWT가 유용한 상황은 “여러 서비스가 같은 토큰을 검증해야 하고, 매번 중앙 세션 저장소를 조회하기 어렵다”는 조건이다. 반대로 로그아웃 즉시 반영, 계정 정지, 디바이스별 세션 제어가 핵심이면 JWT만으로는 부족하다. 결국 jti 블랙리스트나 refresh token 저장소가 필요해지고, 이 순간 일부 상태를 다시 서버가 갖게 된다.
JWT 검증의 최소 조건
1. 허용 알고리즘인지 확인한다. 예: HS256만, 또는 RS256만.2. Signature를 검증한다.3. iss가 내가 신뢰하는 발급자인지 확인한다.4. aud가 이 API인지 확인한다.5. exp, nbf를 현재 시각과 비교한다.6. 필요한 경우 jti가 폐기 목록에 없는지 확인한다.7. 그 뒤에야 sub, role, scope를 인가 입력으로 사용한다.alg: none 공격은 1번을 빼먹었을 때 생긴다. JWT 헤더는 클라이언트가 보낼 수 있는 값이므로, 서버가 헤더의 alg를 그대로 믿으면 서명 없는 토큰을 통과시킬 수 있다. 라이브러리 기본값에 기대지 말고 허용 알고리즘을 명시한다.
// 핵심만 남긴 안전한 JWT 검증 설정 예시JwtModule.register({ secret: process.env.JWT_SECRET, signOptions: { expiresIn: "15m", algorithm: "HS256", issuer: "my-app", audience: "my-api", }, verifyOptions: { algorithms: ["HS256"], issuer: "my-app", audience: "my-api", clockTolerance: 60, },});clockTolerance: 60은 분산 서버의 시계 오차를 60초까지 허용한다는 뜻이다. 실무에서는 exp와 nbf 검증에 1~5분 정도의 leeway를 두는 경우가 많다. 이 값이 너무 크면 만료 토큰이 오래 살아남고, 너무 작으면 서버 시각 차이 때문에 정상 사용자가 401을 볼 수 있다.
서버 저장소에 로그인 상태를 둔다. 즉시 로그아웃, 계정 정지, 디바이스 제어가 쉽다.
내부 어드민, 단일 서비스, 보안 이벤트 대응이 중요한 서비스서명된 토큰을 각 API 서버가 검증한다. 중앙 조회를 줄이고 서비스 간 전달이 쉽다.
여러 API 서버, 외부 검증자, 짧은 Access Token 중심 구조잘못된 선택의 대표 사례는 “마이크로서비스니까 무조건 JWT”라고 고르는 것이다. 내부 어드민처럼 계정 정지와 로그아웃 즉시 반영이 중요한 서비스에 JWT를 쓰면, 결국 모든 요청에서 Redis 블랙리스트나 세션 상태를 다시 확인하게 된다. 그러면 stateless 이점은 사라지고 토큰 크기와 키 관리 비용만 남는다.
반대로 여러 독립 API가 같은 사용자 인증을 검증해야 하고 인증 서버 호출을 줄여야 한다면 JWT가 자연스럽다. 이때도 Access Token 수명은 짧게 잡고, 장기 세션은 Refresh Token 쪽에서 관리한다.
Access Token은 API 접근에 직접 쓰는 짧은 토큰이다. Refresh Token은 새 Access Token을 받기 위한 긴 토큰이다. 둘을 나누는 이유는 “자주 쓰는 토큰은 짧게, 오래 가는 권한은 더 강하게 추적”하기 위해서다.
권장 감각은 다음과 같다.
| 토큰 | 일반적인 수명 | 이유 |
|---|---|---|
| Access Token | 외부 API 5~15분, 내부 저위험 서비스 최대 60분 | 탈취돼도 피해 시간을 줄인다. |
| Refresh Token | 7~14일 또는 정책상 더 짧게 | 사용자 경험을 유지하되 서버가 저장·회전·폐기한다. |
Refresh Token Rotation은 refresh token을 사용할 때마다 새 refresh token으로 교환하고, 이전 refresh token은 즉시 폐기하는 방식이다.
sequenceDiagram participant C as Client participant A as Auth Server participant X as Attacker A-->>C: Access Token + Refresh Token-A 발급 C->>A: Token-A로 재발급 요청 A-->>C: Token-A 폐기, Access Token + Refresh Token-B 발급 X->>A: 탈취한 Token-A 재사용 시도 A-->>X: 재사용 감지, 세션 토큰 무효화
Rotation이 없으면 refresh token 하나가 탈취됐을 때 공격자가 만료 전까지 계속 새 access token을 받을 수 있다. Rotation이 있으면 이미 사용된 refresh token이 다시 들어오는 순간 “정상 사용자와 공격자 중 하나가 토큰을 공유하고 있다”는 신호로 볼 수 있다. 여기서 refresh token family는 한 로그인 세션에서 순차적으로 발급된 refresh token들의 체인이다. 이 경우 해당 family를 모두 폐기하고 재로그인을 요구하는 것이 안전하다. Refresh Token 저장소에는 원문 토큰을 그대로 두지 않는 편이 안전하다. 데이터베이스나 Redis snapshot이 유출돼도 바로 재사용하지 못하도록, 충분히 긴 랜덤 토큰을 발급하고 서버에는 해시값과 family id, 만료 시각, 폐기 여부를 저장한다.
재사용 감지의 핵심은 원자성이다.
나쁜 흐름:1. Redis에서 refresh token을 읽는다.2. 요청 토큰과 비교한다.3. 새 token을 저장한다.
동시 요청 2개가 1번을 거의 동시에 통과하면 둘 다 성공할 수 있다.
더 안전한 흐름:1. GETDEL, Lua script, transaction, SET NX 같은 원자 연산으로 기존 토큰을 회수한다.2. 회수된 값이 요청 토큰과 일치할 때만 새 토큰을 저장한다.3. 불일치하거나 이미 없으면 reuse로 보고 세션을 폐기한다.이 설계는 보안상 fail-closed여야 한다. Redis나 토큰 저장소 조회가 실패했을 때 “사용자 경험을 위해 통과”시키면 저장소 장애가 인증 우회가 된다. 오류가 나면 401로 막고 재시도를 유도하는 쪽이 맞다.
OAuth 2.0은 인증 프로토콜이 아니라 권한 위임(authorization delegation) 프로토콜이다. “이 앱이 내 Google Drive 파일을 읽어도 된다”처럼 사용자가 리소스 접근 권한을 제3자 앱에 위임하는 표준이다.
OIDC(OpenID Connect)는 OAuth 2.0 위에 인증 레이어를 얹은 표준이다. Authorization Code Flow를 사용하되, Access Token 외에 ID Token을 받아 “이 사용자가 누구인지”를 확인한다.
| 구분 | OAuth 2.0 | OIDC |
|---|---|---|
| 주된 질문 | 이 앱이 어떤 리소스에 접근해도 되는가? | 이 사용자는 누구인가? |
| 핵심 토큰 | Access Token | ID Token + Access Token |
| 예시 | Google Calendar 읽기 권한 위임 | Google로 로그인 |
| 필수 스코프 | 서비스마다 다름 | openid |
sequenceDiagram participant U as User participant B as Browser participant App as App Server participant IdP as Identity Provider U->>B: "Google로 로그인" 클릭 B->>IdP: client_id, redirect_uri, scope=openid email, state, code_challenge IdP-->>B: callback?code=...&state=... B->>App: Authorization Code 전달 App->>IdP: code + code_verifier를 토큰으로 교환 IdP-->>App: ID Token + Access Token App->>App: ID Token 검증 후 자체 세션/JWT 발급
Authorization Code를 한 번 더 교환하는 이유는 브라우저 주소창과 로그에 Access Token을 직접 노출하지 않기 위해서다. code는 짧게 살고 일회성이다. 서버가 code를 token endpoint로 보내 실제 토큰을 받는다.
PKCE(Proof Key for Code Exchange)는 SPA나 모바일 앱처럼 client_secret을 안전하게 보관하기 어려운 환경에서 code 탈취를 막기 위한 보강이다. 클라이언트가 code_verifier를 만들고 그 해시인 code_challenge를 인증 요청에 보낸다. 나중에 토큰 교환 시 원본 code_verifier를 제출해야 하므로, 중간에서 code만 훔친 공격자는 토큰을 받을 수 없다.
OAuth/OIDC에서 state는 CSRF 방어에 중요하다. 로그인 요청을 시작할 때 만든 난수를 서버 세션이나 안전한 저장소에 보관하고, callback의 state와 일치하는지 확인한다. Invalid state는 단순 귀찮은 에러가 아니라 “내가 시작하지 않은 인증 응답일 수 있다”는 신호다.
OIDC의 ID Token을 검증할 때는 JWT 일반 검증(iss, aud, exp, 서명)과 함께 nonce도 본다. nonce는 로그인 요청과 ID Token 응답을 묶어 replay된 인증 응답을 걸러내기 위한 값이다.
인가 시스템은 구현 방식이 달라도 대체로 다음 순서로 생각한다.
input: subject = user:alice action = order:refund resource = order:123 context = { ownerId: "alice", amount: 30000, ip: "203.0.113.10" }
policy: allow support_agent to refund order when amount <= 50000 deny refund when order.status = "settled_90_days_ago"
decision: explicit deny가 있으면 deny allow 규칙이 있으면 allow 아무 규칙도 맞지 않으면 deny핵심 원칙은 deny-by-default다. 명시적으로 허용되지 않은 조합은 거부해야 한다. allow-by-default는 정책 누락이 곧 권한 상승(privilege escalation)이 되는 구조다. 또 explicit deny가 있으면 allow보다 우선해야 한다. 조직 정책, 법적 제한, 위험 상태를 allow 규칙으로 덮어쓰면 안 되기 때문이다.
RBAC(Role-Based Access Control)는 역할에 권한을 묶고, 사용자에게 역할을 부여하는 방식이다.
user:alice -> role:editorrole:editor -> permission:article:createrole:editor -> permission:article:updaterole:viewer -> permission:article:readNestJS로 보면 컨트롤러의 @Roles("admin") 같은 선언은 “이 action/resource 조합은 admin role이 있어야 한다”는 정책을 코드에 표현한 것이다.
// 핵심 흐름만 남긴 RBAC 예시@UseGuards(JwtAuthGuard, RolesGuard)@Delete(":id")@Roles("admin")remove(@Param("id") id: string, @Request() req) { // JwtAuthGuard: req.user를 만든다. AuthN. // RolesGuard: req.user.roles에 admin이 있는지 확인한다. AuthZ. return this.ordersService.remove(id);}요청 결과는 다음처럼 갈린다.
GET /ordersAuthorization: Bearer <valid-token>-> 200 OK
GET /ordersAuthorization: 없음-> 401 Unauthorized
DELETE /orders/1Authorization: Bearer <viewer-token>-> 403 ForbiddenRBAC의 장점은 단순하고 감사하기 쉽다는 점이다. 단점은 조건이 늘어나면 역할 폭발(role explosion)이 생긴다는 점이다. Admin-Asia-BusinessHours-OwnTeamOnly처럼 지역, 시간, 소유 관계, 데이터 민감도까지 역할 이름에 밀어 넣기 시작하면 RBAC가 더 이상 단순하지 않다.
ABAC(Attribute-Based Access Control)는 subject, resource, environment의 속성을 정책에 넣는다.
allow if subject.department == resource.ownerDepartment and action == "read" and request.ip in corporate_networkPBAC(Policy-Based Access Control)는 정책 언어와 정책 엔진을 중심에 둔다. OPA/Rego, Cedar 같은 도구가 이 범주에 들어간다. 정책을 애플리케이션 코드에서 분리해 여러 서비스가 같은 방식으로 평가하게 만들 수 있다.
역할과 권한 매핑으로 대부분의 CRUD 권한을 단순하게 표현한다.
역할 수가 적고 정책을 사람이 감사해야 할 때부서, 소유자, 시간, 위치, 리소스 민감도 같은 속성으로 판단한다.
역할 이름에 조건이 계속 붙어 role explosion이 시작될 때정책 엔진과 정책 언어로 allow/deny를 중앙화한다.
여러 서비스와 조직 정책을 일관되게 평가해야 할 때처음부터 PBAC를 도입할 필요는 없다. L1에서 중요한 판단은 “role만 보면 충분한가, 아니면 요청 맥락과 리소스 속성이 정책의 일부인가?”다. 맥락이 정책의 핵심이면 RBAC만으로는 부족하다.
Least privilege는 주체에게 필요한 최소 권한만 주는 원칙이다. “나중에 막는 것”이 아니라 “처음에는 닫고 필요한 것만 여는 것”이다.
권한 상승은 보통 극적인 해킹보다 작은 누락에서 시작한다.
orderId만 검증하고 ownerId를 확인하지 않는다.viewer에게 읽기 권한만 줬지만 토큰의 permissions claim이 오래 살아 과거 권한이 남는다.admin=true 헤더를 신뢰한다.ListBucket만 필요했는데 s3:*를 붙인다.정책 리뷰에서는 항상 “이 allow가 너무 넓지 않은가?”와 “명시적 deny 또는 기본 deny가 있는가?”를 확인한다.
401은 인증 실패다. 이름은 Unauthorized지만 실제 의미는 “인증되지 않았거나 인증을 다시 해야 한다”에 가깝다. 서버는 보통 WWW-Authenticate 헤더나 에러 메시지로 어떤 인증이 필요한지 알려준다.
401에서 먼저 볼 것:
Bearer 접두사와 공백이 맞는가?exp가 지났는가?iss, aud, nbf, clock skew 검증에서 막혔는가?jti가 폐기됐는가?403은 인증은 됐지만 인가가 거부됐다는 뜻이다. 재로그인으로 해결될 수도 있지만, 기본적으로는 정책·역할·리소스 scope 문제다.
403에서 먼저 볼 것:
| 증상 | 더 가까운 문제 | 먼저 볼 곳 |
|---|---|---|
| 모든 사용자가 동시에 401 | 인증 키/issuer/audience/배포 문제 | JWT secret, JWKS, IdP 설정 |
| 특정 사용자만 401 | 토큰 만료, 계정 폐기, refresh 재사용 | exp, jti, 세션 상태 |
| 로그인은 되는데 관리자 API만 403 | 인가 정책 문제 | role, permission, policy |
| 본인 주문 조회가 403 | 리소스 scope 또는 owner 조건 | ownerId, tenant, resource policy |
OAuth callback에서 Invalid state | CSRF 방어 또는 redirect mismatch | 저장된 state, callback URL |
| 토큰이 큰 사용자만 실패 | 헤더 크기 제한 | claim 수, 쿠키 합산 크기, proxy limit |
첫 회독에서는 HS256은 shared secret, RS256은 private/public key라는 차이와 “키를 어디까지 배포해야 하는가”만 잡아도 충분하다. RSA 세부 수학이나 JWKS 운영은 여러 서비스가 같은 토큰을 검증하는 구조를 다룰 때 다시 돌아오면 된다. 공개키 암호화(RSA) 기초는 이 선택 심화 절을 깊게 읽을 때만 필요하다. 인증과 인가의 기본 흐름을 읽기 위한 hard prerequisite는 아니다.
HS256은 하나의 비밀 키로 서명과 검증을 모두 한다. 단일 서버나 검증자가 매우 적은 환경에서는 단순하고 빠르다. 단점은 검증자 모두가 같은 비밀 키를 알아야 한다는 점이다. 검증 서버 중 하나가 뚫리면 서명 키도 뚫릴 수 있다.
HS256을 쓴다면 password, my-secret 같은 사람이 만든 문자열은 secret이 아니다. 최소 256-bit 수준의 랜덤 secret을 쓰고, 환경변수 파일에 흩뿌리기보다 Secret Manager나 KMS 같은 저장소로 배포 범위와 회전 절차를 관리해야 한다.
RS256은 개인 키로 서명하고 공개 키로 검증한다. 인증 서버만 개인 키를 갖고, API 서버들은 공개 키만 갖는다. 공개 키는 유출돼도 서명에는 쓸 수 없으므로 여러 서비스가 토큰을 검증하는 환경에 더 적합하다.
하나의 shared secret으로 서명과 검증을 모두 수행한다.
단일 서비스, 키 배포 대상이 거의 없고 회전 범위가 작을 때개인 키로 서명하고 공개 키로 검증한다. 검증자는 비밀을 몰라도 된다.
마이크로서비스, 외부 검증자, JWKS 기반 키 회전이 필요할 때성능만으로 고르지 않는다. 10K req/s 수준에서도 RSA 검증 비용보다 네트워크 I/O, DB 조회, 애플리케이션 로직이 더 큰 경우가 많다. 선택 기준은 키 분배와 회전 모델이다. 흔한 구성을 명시해서 비교해보자. 2048-bit RSA key와 compact JWS의 padding 없는 base64url 인코딩을 가정하면 RS256 signature는 raw 256 bytes가 342 ASCII bytes가 되고, HS256은 SHA-256 HMAC raw 32 bytes가 43 ASCII bytes가 된다. 따라서 같은 header와 payload를 쓴 토큰에서 signature segment 차이는 약 299 bytes이며, 이는 AWS ALB의 전체 요청 헤더 64 KiB(65,536 bytes) 한도 중 299 / 65,536 ≈ 0.46%다. 이 값은 특정 key size와 인코딩을 둔 common-case 계산이지 모든 JWT의 고정 증가량이 아니다. 따라서 보통은 “토큰 크기”보다 “비밀 키를 몇 곳에 뿌리는가”가 더 큰 결정 요인이다. 다만 실제 실패 여부는 토큰 하나가 아니라 cookie와 프록시가 추가한 헤더까지 합친 전체 크기로 판단한다.
모놀리스에서 4개 서비스로 분리되는 상황을 생각해보자. HS256을 유지하면 API, Order, Payment, Notification 서비스와 CI, 로컬 개발 환경 모두 같은 JWT_SECRET을 가져야 한다. 키를 교체하려면 모두 동시에 바뀌어야 하고, 하나라도 오래된 키를 쓰면 401이 난다. RS256 + JWKS를 쓰면 인증 서버만 개인 키를 갖고, 나머지는 /.well-known/jwks.json의 공개 키를 캐시해 검증한다.
JWKS(JSON Web Key Set)는 공개 키 목록을 JSON으로 제공하는 방식이다. JWT header의 kid는 어떤 키로 서명했는지 알려주는 key id다.
무중단 키 전환의 기본 흐름:
1. 새 키 쌍을 만든다. kid = key-v2.2. JWKS에 기존 key-v1과 새 key-v2를 함께 노출한다.3. 새 토큰은 key-v2로 서명한다.4. 기존 key-v1 토큰은 access/refresh 정책상 유예 기간 동안 계속 검증한다.5. 유예 기간이 끝나면 JWKS에서 key-v1을 제거하고 개인 키를 폐기한다.HS256의 단일 비밀 키를 즉시 바꾸면 기존 access token은 모두 깨진다. 이 상황이 보안 사고 대응이라면 의도한 전체 무효화일 수 있다. 하지만 일반 키 회전이라면 사용자 전체 재로그인을 만들지 않도록 이전 키와 새 키를 함께 검증할 수 있는 구조가 필요하다.
첫 회독에서는 kid가 “어떤 공개 키로 검증할지 고르는 힌트”이고, JWKS가 “공개 키 목록을 배포하는 표준 위치”라는 정도만 잡아도 된다.
JWT는 보통 HTTP header나 cookie로 전송된다. 그래서 claim을 많이 넣으면 인프라 제한에 걸린다.
| 인프라 | 헤더 크기 감각 |
|---|---|
Nginx 기본 large_client_header_buffers | 버퍼 4개 x 8KB |
| AWS ALB | 단일 헤더 16KB, 전체 요청 헤더 64KB |
| Cloudflare | 단일 헤더 값 16KB 수준 |
역할, 권한, 조직, 기능 플래그를 모두 claim에 넣으면 토큰이 1~3KB 이상으로 커질 수 있다. 여기에 쿠키 여러 개가 합쳐지면 특정 사용자만 431 또는 400 계열 오류를 볼 수 있다. 상세 권한은 DB/Redis에서 조회하고 토큰에는 안정적인 식별자와 최소 claim만 넣는 편이 안전하다.
요구사항:
정책을 말로 쓰면 다음과 같다.
deny if subject.status == "suspended"allow if action == "order:read" and resource.ownerId == subject.idallow if action == "order:delete" and subject.roles includes "admin"deny otherwise세 요청을 평가해보자.
| Subject | Action | Resource | Decision | 이유 |
|---|---|---|---|---|
alice(user) | order:read | order.ownerId=alice | allow | 본인 리소스 읽기 |
alice(user) | order:delete | order.ownerId=alice | deny | 본인 주문이어도 삭제 권한 없음 |
bob(admin, suspended) | order:delete | order.ownerId=alice | deny | explicit deny가 admin allow보다 우선 |
이 예제의 핵심은 “역할이 높으면 항상 허용”이 아니라는 점이다. 보안 상태, 리소스 소유자, action의 위험도가 정책의 일부다.
S3 접근 거부도 같은 모델로 본다.
Subject: arn:aws:iam::123456789012:role/app-serverAction: s3:GetObjectResource: arn:aws:s3:::my-bucket/orders/2026-07/report.csvDecision: explicitDeny, implicitDeny, or allow실전에서 긴 CLI를 외울 필요는 없다. 먼저 3축을 채운다.
s3:GetObject인가, s3:ListBucket인가? 둘은 다른 권한이다.ListBucket은 버킷, GetObject는 객체 scope가 필요하다.선택 확인 명령은 다음 정도면 학습 목적에 충분하다.
aws sts get-caller-identity
aws iam simulate-principal-policy \ --policy-source-arn "arn:aws:iam::123456789012:role/app-server" \ --action-names "s3:GetObject" \ --resource-arns "arn:aws:s3:::my-bucket/orders/2026-07/report.csv"implicitDeny는 “허용 정책을 못 찾았다”는 뜻이고, explicitDeny는 “거부 정책이 직접 막았다”는 뜻이다. 둘 다 거부지만 해결 방향이 다르다. implicit deny는 필요한 allow를 추가할지 검토하고, explicit deny는 조직 정책·SCP·bucket policy·permission boundary를 먼저 확인한다.
Kubernetes RBAC는 다음처럼 묻는다.
kubectl auth can-i create pods --namespace=devkubectl auth can-i delete secrets --namespace=prod --as=janePostgreSQL은 다음처럼 묻는다.
SELECT has_table_privilege('alice', 'orders', 'SELECT');명령은 다르지만 질문은 같다. “이 subject가 이 action을 이 resource에 대해 할 수 있는가?”라는 모델이 먼저이고, 도구 명령은 그 모델을 확인하는 방법일 뿐이다.
Access Token이 로그, 브라우저 localStorage, URL query string, 에러 리포트에 남으면 공격자가 그대로 replay할 수 있다. 서명 검증은 “발급자가 맞다”를 확인할 뿐 “토큰을 들고 온 사람이 원래 사용자다”를 보장하지 않는다.
실패 신호:
Authorization: Bearer ...가 남는다.jti나 동일 refresh token이 다른 IP/디바이스에서 재사용된다.대응 기준:
세션 쿠키 기반 인증은 CSRF에 취약할 수 있다. 브라우저가 쿠키를 자동 전송하기 때문이다. 반면 JWT를 JS 메모리나 localStorage에 두면 CSRF는 줄어들 수 있지만 XSS로 토큰을 직접 훔칠 위험이 커진다.
| 저장 위치 | 강한 점 | 약한 점 |
|---|---|---|
| HttpOnly Secure SameSite cookie | JS가 토큰을 읽기 어렵다. | CSRF 방어와 SameSite 설계가 필요하다. |
| JS 메모리 | 새로고침 시 사라져 노출 시간이 짧다. | 탭 유지와 silent refresh 설계가 필요하다. |
| localStorage | 구현이 쉽고 오래 유지된다. | XSS가 있으면 토큰 탈취가 쉽다. |
정답은 저장 위치 하나가 아니라 위협 모델이다. 중요한 것은 토큰 누출, replay, CSRF, XSS 중 무엇을 더 크게 보는지 명확히 하고 보완책을 같이 두는 것이다.
리소스 존재 여부 자체가 민감하면 403 대신 404를 반환할 수 있다. 예를 들어 /users/{id}/medical-records에서 “권한은 없지만 리소스는 존재함”을 알려주는 것 자체가 정보 노출일 수 있다.
하지만 내부 로그와 정책 decision은 403/deny로 남아야 한다. 클라이언트 응답은 404로 감추더라도 서버 관측성에서 인증 실패와 인가 실패를 구분하지 못하면 운영 대응이 어려워진다.
JWT에 role: "admin"을 넣으면 API 서버는 DB 조회 없이 빠르게 판단할 수 있다. 하지만 사용자가 admin에서 내려간 뒤에도 기존 토큰이 만료되기 전까지는 claim이 남는다. 이 문제를 줄이는 선택지는 세 가지다.
jti나 token family를 폐기한다.모든 권한을 토큰에 넣는 것은 단순해 보이지만, 변경이 잦은 권한일수록 stale claim 위험이 커진다.
이 절은 긴 런북이 아니라 증상에서 개념으로 돌아가는 빠른 지도다.
| 증상 | 의미 | 확인할 개념 |
|---|---|---|
Unknown authentication strategy 'jwt' | 프레임워크가 JWT 검증기를 등록하지 못함 | 인증 미들웨어/Guard 구성 |
| 토큰이 있는데 401 | 토큰 전달 또는 검증 실패 | Bearer 형식, exp, secret/JWKS, iss/aud |
| 인증은 됐는데 403 | 정책 평가에서 deny | role, permission, owner, resource scope |
OAuth Invalid state | callback이 시작 요청과 매칭되지 않음 | CSRF 방어, redirect URI |
| 로그아웃 후 API 성공 | access token이 아직 유효 | 짧은 수명, jti 폐기, blacklist |
| refresh 동시 요청이 둘 다 성공 | rotation 원자성 실패 | GETDEL, transaction, token family 폐기 |
| 특정 사용자만 헤더 오류 | claim/cookie 크기 초과 | claim 최소화, 권한 조회 분리 |
JWT payload는 디버깅 목적으로만 디코딩한다. 디코딩은 검증이 아니다.
TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzQzNTAwMDAwfQ.xxx"echo "$TOKEN" | cut -d "." -f2 | base64 -d 2>/dev/nullRedis 블랙리스트는 “토큰을 서버가 기억하고 있는가?”를 확인하는 용도다.
redis-cli GET "blacklist:<jti>"NestJS의 Unknown authentication strategy 'jwt'는 보통 JwtStrategy provider 등록이나 PassportModule import 누락이다. 전체 설정 파일을 외우기보다 “요청 전에 인증 전략이 DI 컨테이너에 등록됐는가?”로 보면 된다.
sub, iss, aud, exp, iat, 필요 시 jti는 기본으로 본다.JwtAuthGuard와 RolesGuard가 각각 AuthN/AuthZ임을 분리해 본다.AccessDenied를 principal/action/resource/decision으로 읽는다.SAML, MFA, OIDC Discovery, JWKS, PKCE, DPoP, mTLS, device binding, SCIM, OPA/Rego, Cedar, AWS IAM SCP, permission boundary, tenant isolation, CSRF, XSS
아래 항목은 첫 회독의 완료 조건이 아니다. 건너뛴 선택 심화 절을 다시 읽은 뒤 복습한다.
| 질문 | 먼저 볼 것 |
|---|---|
| ”누구인가?” | Authentication, session, JWT, OIDC ID Token |
| ”무엇을 해도 되는가?” | Authorization, RBAC/ABAC/PBAC, policy decision |
| 401이 뜨는가? | 토큰·쿠키 전달, 서명, 만료, issuer/audience |
| 403이 뜨는가? | role, permission, owner, action, resource scope |
| JWT를 쓸까? | 분산 검증 필요성과 즉시 무효화 요구를 비교 |
| OAuth만 쓰면 로그인인가? | 아니다. 신원 확인은 OIDC ID Token 검증이 필요 |
| Refresh Token은 왜 회전하나? | 탈취된 긴 수명 토큰의 재사용을 감지하기 위해 |
| 정책 기본값은? | deny-by-default, least privilege |
5줄로 줄이면 다음과 같다.