--- id: 87000d59-b69f-4010-9481-0b71c8bde32d kind: CONCEPT slug: bearer-jwt-validation-chain title: Bearer JWT가 인증된 principal이 되기까지 topic: oauth-oidc-auth-boundary topicName: OAuth/OIDC 인증 경계 project: KeyCloak Patterns status: 게시 전 version: 4 basisVersion: Keycloak 26.7.0 · Spring Security OAuth2 Resource Server studio: "https://hyeonworks.com/studio/documents/87000d59-b69f-4010-9481-0b71c8bde32d/edit" sourceRevision: keycloak-patterns-lab@2026-08 source: - final/document.md#선택의-이유와-지킨-경계-ap1 --- # Bearer JWT가 인증된 principal이 되기까지 Resource Server가 받는 입력은 `Authorization` 헤더에 실려 온 문자열 하나다. 이 문자열은 서명 검증, issuer와 시간 검증, audience 검증, 역할 변환을 차례로 지나야 인증된 요청 주체(principal)가 된다. OAuth 2.0과 JWT를 한 번이라도 다뤄 본 사람을 대상으로, 먼저 Resource Server가 받는 입력부터 이 사슬이 단계마다 무엇을 확인하고 무엇을 다음 단계로 넘기는지 따라간다. 서명 검증을 통과했다고 이 API를 위해 발급된 토큰인 것은 아니고, 그 확인은 audience 검증이 따로 맡는다. ## 관계 - **OAuth Token과 Application Session을 구분하는 기준** 이 검증을 통과한 JWT와 애플리케이션 세션은 다른 상태다. - **Authorization Code Flow의 Endpoint와 Credential 이동 기준** 이 JWT가 어느 엔드포인트에서 발급되는지 정리한 기록이다. - **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계** 브라우저가 이 헤더를 직접 만든 구성이다. ## 본문 ## Resource Server가 받는 입력 브라우저가 보내든 BFF(Backend For Frontend, 프런트엔드 전용 백엔드)가 보내든 요청의 모양은 같다. ```http label="Resource Server 입력" GET http://localhost:8081/api/me Authorization: Bearer ``` Spring이 받는 것은 이 헤더에 실려 온 문자열 그대로다. 요청마다 JWT로 인증하고 애플리케이션 세션을 만들지 않으려고 `SecurityConfig.apiSecurity()`는 CORS를 켜고 CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 보호를 끄며 `SessionCreationPolicy.STATELESS`를 선택한다. 세션을 만들지 않으니 로그아웃하는 순간에 서버에서 지울 상태도 없다. 이미 발급된 JWT는 필요한 값을 자기 안에 담고 있어서 만료 전까지 유효하고, 짧은 TTL(Time To Live, 유효 시간)과 검증기가 그 범위를 좁힌다. ## 변환 순서 직접 만든 코드가 지나는 순서는 다음과 같다. ```text label="raw Bearer JWT가 principal이 되기까지" raw Bearer JWT → NimbusJwtDecoder(JWK signature) → default issuer + timestamp validators → AudienceValidator("keycloak-pattern-api") → validated Jwt → KeycloakRealmRoleConverter → authenticated principal + ROLE_* authorities ``` 헤더를 꺼내 JWT 인증 제공자에 넘기고 설정된 디코더를 부르는 일은 Spring OAuth2 Resource Server가 한다. 저장소의 코드는 Spring 내부 필터를 직접 만들지 않으므로, 이 순서에는 설정 DSL(Domain Specific Language, 설정 전용 문법)이 붙여 주는 부분과 직접 만들어 끼운 빈이 함께 들어 있다. ## 서명 확인 하나로 끝내지 않는 이유 서명이 맞다는 것은 그 IdP(Identity Provider, 인증 제공자)가 발급했다는 뜻이다. 서명 검증에 쓰는 JWK(JSON Web Key, 공개키를 JSON으로 표현한 형식)는 그 IdP의 공개키를 담고 있을 뿐이어서, 같은 IdP가 다른 API를 위해 발급한 토큰도 서명은 똑같이 맞다. 그래서 서명만 확인하고 끝내지 않는다. | 확인 단계 | 확인하는 것 | 이 단계가 답하지 못하는 것 | |---|---|---| | JWK signature | 이 realm이 발급했는가 | 어느 API를 위한 토큰인가 | | issuer | 기대한 realm인가 | 아직 유효한가 | | timestamp | 만료 전인가 | 이 API가 대상인가 | | audience | 이 API를 위해 발급됐는가 | 무엇을 할 수 있는가 | | 역할 변환 | 어떤 권한을 갖는가 | — | ## expected issuer와 JWK URL이 다른 이유 두 값은 같은 realm을 가리키지만 쓰임이 다르다. ```text label="issuer와 JWK URL" expected issuer = http://localhost:8080/realms/keycloak-patterns JWK URL = http://keycloak:8080/.../certs ``` expected issuer는 토큰 안에 적혀 브라우저까지 보이는 값이다. 브라우저가 도달하는 주소로 발급됐으니 클레임을 검증하는 기준도 그 주소여야 한다. JWK URL은 공개키를 가져오는 컨테이너 네트워크 경로이고, Resource Server는 같은 Docker 네트워크 안에서 서비스 이름으로 Keycloak에 도달한다. 두 값을 같게 맞추려고 expected issuer를 컨테이너 주소로 바꾸면 브라우저가 받은 토큰의 `iss`와 어긋난다. ## audience 검증 audience는 이 토큰이 어느 API를 위해 발급됐는지 담는 값이다. `AudienceValidator`는 `jwt.getAudience()`에 `keycloak-pattern-api`가 들어 있는지 확인하고, 없으면 `invalid_token` 결과를 만든다. 정상적으로 발급받은 같은 JWT를 expected audience가 다른 진단용 Resource Server에 그대로 제출하면 401이 돌아온다. expected issuer가 다른 서버도 마찬가지로 401이다. 다만 이 두 서버가 재 준 범위는 audience 검증과 issuer 검증까지다. 서명이 틀린 JWT나 만료된 JWT를 넣는 전용 E2E 계약은 만들지 않았다. 단위 테스트에 합성 JWT를 주입해 컨트롤러가 200을 반환하는 것은 확인할 수 있지만, 그 200은 그 요청이 실제 `NimbusJwtDecoder`의 서명 검증과 issuer 검증을 지났다는 증거가 아니다. 사슬의 앞 두 단계는 이 계약 안에서 부정 입력으로 확인하지 않았다. ## realm role이 authority가 되는 변환 `KeycloakRealmRoleConverter`는 `realm_access.roles`에 들어 있는 문자열을 골라 앞에 `ROLE_`을 붙인다. ```text label="role 변환" realm_access.roles: ["user-role"] → ROLE_user-role ``` Spring Security의 `hasRole("user-role")`이 `ROLE_user-role`이라는 이름의 권한을 찾기 때문에 이 접두사가 필요하다. ## 인증과 인가는 다른 endpoint에서 갈린다 `/api/me`는 특정 역할을 요구하지 않고 `.authenticated()`만 요구한다. 그래서 역할 클레임이 없어 변환 결과가 빈 목록이어도, JWT가 유효하기만 하면 `/api/me`는 통과한다. `admin-role`의 효과는 `/api/admin`에서 나타난다. 일반 사용자는 403, 관리자 사용자는 200이다. `ApiController`는 검증을 마친 JWT에서 값을 꺼내 사용자 JSON을 만든다. ```json label="ApiController가 반환하는 JSON" { "subject": "", "username": "regular-user", "issuer": "http://localhost:8080/realms/keycloak-patterns", "audience": ["", "keycloak-pattern-api"] } ``` 이 JSON의 `regular-user`는 프록시가 만들어 붙이는 identity header에도 똑같이 들어갈 수 있는 값이다. 화면에 보이는 이름은 같아도 그 값을 무엇이 보증했는지는 다르다. 여기서는 서명과 issuer, audience를 확인한 JWT에서 꺼냈다.