Skip to content

필사 모드: 인증과 인가 완전 가이드: 스펙 원문으로 짚는 열 가지 오해

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며

이 블로그에는 이미 OAuth 2.0 완전 정복JWT와 세션, 무엇을 언제, 그리고 프레임워크별 SSO 시리즈가 있습니다. 그 글들은 프로토콜이 어떻게 동작하는지를 설명합니다. 이 글은 각도가 다릅니다. 현장에서 반복되는 오해가 정확히 어느 지점에서 발생하는지를, 스펙 원문 문장으로 교정하는 것이 목표입니다.

그리고 시작하기 전에 한 가지를 분명히 해 두겠습니다. 이 글은 레시피가 아닙니다. 보안 권고는 전제가 붙어야 의미가 있습니다. "쿠키에 넣으세요", "JWT를 쓰세요" 같은 문장은 전제를 생략하는 순간 틀린 조언이 됩니다. 그래서 이 글은 권고마다 그 권고가 성립하는 조건을 함께 적습니다. 조건이 다르면 결론도 달라져야 합니다.


1. 인증과 인가는 다른 문제다

  • 인증(Authentication): 요청을 보낸 주체가 누구인지 확인하는 일
  • 인가(Authorization): 확인된 주체가 이 동작을 해도 되는지 판단하는 일

이 구분은 개념 설명용 수사가 아닙니다. HTTP 프로토콜 자체가 두 상태를 서로 다른 코드로 나눠 놓았습니다. RFC 9110 §15.5.2는 401을 "대상 리소스에 대한 유효한 인증 자격 증명이 없기 때문에 요청이 적용되지 않았다"로, §15.5.4는 403을 "서버가 요청을 이해했지만 이행을 거부한다"로 정의합니다. 401은 인증 문제, 403은 인가 문제입니다.

401의 이름이 Unauthorized인 것은 역사적 실수에 가깝습니다. 이름은 인가처럼 보이지만 의미는 인증입니다.

실무에서 이 구분이 중요한 이유는 클라이언트의 동작이 달라지기 때문입니다.

  • 로그인하지 않은 사용자에게 403을 주면, 클라이언트는 재로그인 흐름을 시작할 근거를 잃습니다.
  • 권한이 없는 사용자에게 401을 주면, 클라이언트는 다시 로그인하고 또 401을 받는 무한 루프에 빠집니다.

다만 여기에는 의도적인 예외가 있습니다. 리소스의 존재 자체를 숨겨야 하는 경우 403 대신 404를 주는 선택이 있습니다. 다른 사용자의 문서 ID를 넣었을 때 403이 돌아오면 "그 ID는 존재한다"는 정보가 새어 나가기 때문입니다. 이것은 정확한 의미 전달과 정보 노출 최소화 사이의 교환이며, 어느 쪽이 옳다고 일반화할 수 없습니다. 결정하고 문서에 적으면 됩니다.


2. OAuth 2.0은 인가 프레임워크다 — OIDC가 그 위에 얹는 것

가장 많이 반복되는 오해입니다. OAuth 2.0을 로그인 프로토콜로 이해하고 설계를 시작하면 그 뒤의 모든 결정이 어긋납니다.

RFC 6749의 제목은 The OAuth 2.0 Authorization Framework입니다. 문서는 스스로를 인가 프레임워크로 규정하며, 인증 프로토콜이라고 말하지 않습니다. 정의하는 네 역할도 접근 위임의 언어로 되어 있습니다.

역할RFC 6749 §1.1의 정의
자원 소유자(Resource Owner)보호된 자원에 대한 접근을 허용할 수 있는 주체. 사람이면 최종 사용자
클라이언트(Client)자원 소유자를 대신해 보호된 자원을 요청하는 애플리케이션
인가 서버(Authorization Server)자원 소유자를 인증하고 인가를 받은 뒤 액세스 토큰을 발급하는 서버
자원 서버(Resource Server)보호된 자원을 호스팅하고 액세스 토큰으로 요청을 받는 서버

여기서 액세스 토큰은 "이 클라이언트가 이 자원에 이만큼 접근해도 된다"라는 증서입니다. "이 사람이 누구다"라는 증명이 아닙니다. 두 문장은 다릅니다.

그래서 액세스 토큰을 그대로 로그인 증거로 쓰는 패턴이 위험합니다. 액세스 토큰에는 "이 토큰이 나(클라이언트)에게 발급된 것인지" 클라이언트가 확인할 표준 장치가 없습니다. 다른 앱을 위해 발급된 토큰을 가져다 내 서버에 제출해도 구분하지 못하는 상황이 생길 수 있습니다.

OIDC가 정확히 이 빈칸을 채웁니다. OpenID Connect Core 1.0의 초록 첫 문장은 이렇습니다. "OpenID Connect 1.0은 OAuth 2.0 프로토콜 위의 단순한 아이덴티티 계층이다." §2는 ID 토큰을 "인가 서버가 수행한 최종 사용자 인증에 관한 클레임을 담은 보안 토큰"으로 정의하며, 형식은 JWT입니다.

ID 토큰의 필수 클레임은 iss, sub, aud, exp, iat이고, aud는 반드시 그 클라이언트의 client_id를 포함해야 합니다. aud 검사가 액세스 토큰에는 없는 장치입니다. 내 client_id가 들어 있지 않은 ID 토큰은 나를 위한 것이 아니므로 거부합니다.

정리하면 이렇습니다.

  • OAuth 2.0만 쓴다 → 접근 위임은 되지만 인증 프로토콜로서의 보증은 없음
  • OIDC를 쓴다 → ID 토큰이라는 검증 가능한 인증 결과가 생김
  • "소셜 로그인"이라 부르는 것 대부분은 사실 OIDC이거나, OAuth 위에 각 공급자가 얹은 비표준 규약

3. 권장에서 빠진 플로우들과 그 대체

OAuth 2.0의 보안 권고는 2012년의 RFC 6749에 머물러 있지 않습니다. RFC 9700(2025년 1월, BCP 240) 이 현재의 보안 모범 사례이며, RFC 6749·6750·6819를 업데이트합니다. 여기서 두 가지 그랜트의 지위가 달라졌습니다.

3-1. 암묵적 그랜트(Implicit) — SHOULD NOT

RFC 9700 §2.1.2는 이렇게 씁니다. "클라이언트는 암묵적 그랜트(응답 타입 token)나 인가 응답에서 액세스 토큰을 발급하는 다른 응답 타입을 사용해서는 안 된다(SHOULD NOT). 단, 인가 응답에서의 액세스 토큰 주입이 방지되는 경우는 예외다."

이유는 두 가지입니다. 액세스 토큰이 인가 응답(프래그먼트, 리다이렉트, 브라우저 히스토리, 리퍼러)을 타고 흐르면서 유출과 재생(replay)에 노출되고, 인가 응답으로 발급된 토큰을 특정 클라이언트에 묶는 표준화된 발신자 제약 방법이 없다는 것입니다.

3-2. 자원 소유자 비밀번호 자격 증명(ROPC) — MUST NOT

§2.4의 표현은 더 강합니다. "자원 소유자 비밀번호 자격 증명 그랜트는 사용해서는 안 된다(MUST NOT)."

이유는 자격 증명을 클라이언트에 그대로 노출시켜 공격면을 넓히고, 2단계 인증이나 여러 단계의 사용자 상호작용이 필요한 인증 절차와 애초에 맞지 않는다는 것입니다.

SHOULD NOT과 MUST NOT의 차이를 흘려보내지 마세요. 암묵적 그랜트는 조건부 금지이고, ROPC는 조건 없는 금지입니다. 문서에 근거를 적을 때 이 차이가 실제로 의미를 가집니다.

3-3. 무엇이 대신 왔는가

현재 권장은 인가 코드 그랜트 + PKCE입니다. RFC 9700 §2.1.1은 세 문장으로 못을 박습니다.

  • "공개 클라이언트는 PKCE를 사용해야 한다(MUST)."
  • "기밀 클라이언트의 경우 PKCE 사용이 권장된다(RECOMMENDED)."
  • "인가 서버는 PKCE를 지원해야 한다(MUST)."

같은 절이 리다이렉트 URI에 대해서도 요구합니다. 인가 서버는 사전 등록된 URI와 정확한 문자열 일치로 비교해야 하며, 네이티브 앱의 localhost 리다이렉트에서 포트 번호만 예외입니다. 부분 일치나 접두사 일치를 허용하는 구현은 이 요구를 위반합니다.

리프레시 토큰도 마찬가지입니다. §2.2.2는 공개 클라이언트의 리프레시 토큰이 발신자 제약(sender-constrained)되거나 회전(rotation)되어야 한다고 요구합니다(MUST).


4. PKCE — 무엇을 막고 무엇을 못 막는가

RFC 7636이 정의하는 PKCE가 막는 것은 인가 코드 가로채기 공격(authorization code interception attack) 입니다. 클라이언트와 서버 사이의 TLS 구간이 아니라, 기기 안의 앱 간 통신처럼 보호되지 않은 경로에서 인가 코드가 탈취되어 공격자가 그것을 토큰으로 바꾸는 시나리오입니다.

동작은 단순합니다.

클라이언트                                     인가 서버
   |                                              |
   |-- code_verifier 생성 (랜덤 32옥텟)            |
   |-- code_challenge = BASE64URL(SHA256(verifier))|
   |                                              |
   |--- 인가 요청 + code_challenge + S256 -------->|
   |                                              |  (challenge 저장)
   |<-- 인가 코드 --------------------------------|
   |                                              |
   |--- 토큰 요청 + code + code_verifier --------->|
   |                                              |  SHA256(verifier) == challenge ?
   |<-- 액세스 토큰 ------------------------------|

code_verifier는 43~128자이며, 스펙은 32옥텟 난수를 생성해 base64url로 인코딩할 것을 권장합니다. code_challenge_methodplainS256 두 가지인데, S256이 서버 구현 필수(MTI)이고 S256을 쓸 수 있는 클라이언트는 S256을 써야 합니다. plain은 제약된 레거시 환경을 위해 남아 있을 뿐입니다.

PKCE가 막지 않는 것도 분명히 해야 합니다.

  • PKCE는 CSRF 방어가 아닙니다. state 파라미터(또는 OIDC의 nonce)는 여전히 필요합니다.
  • PKCE는 이미 발급된 액세스 토큰이 유출된 뒤를 보호하지 않습니다.
  • 인가 서버가 PKCE 다운그레이드를 막지 않으면 무력화됩니다. RFC 9700 §2.1.1은 "인가 요청에 code_challenge가 없었다면 code_verifier가 포함된 토큰 요청을 받아들이지 말 것"을 MUST로 요구합니다. 클라이언트만 PKCE를 붙인다고 끝나지 않는다는 뜻입니다.

5. 세 가지 토큰을 구분하기

이름이 전부 "토큰"이라 뭉뚱그려지지만, 셋은 소비자도 수명도 다릅니다.

토큰누가 검증하는가어디로 보내는가클라이언트가 내용을 읽어도 되는가
액세스 토큰자원 서버자원 서버(API)아니오. 불투명해도 정상
리프레시 토큰인가 서버인가 서버의 토큰 엔드포인트아니오
ID 토큰클라이언트어디에도 보내지 않음예. 그러라고 있는 토큰

여기서 가장 흔한 두 가지 실수가 나옵니다.

첫째, ID 토큰을 API 호출의 Bearer 토큰으로 쓰는 것. ID 토큰은 클라이언트가 "사용자가 인증되었다"는 사실을 확인하기 위한 물건입니다. 자원 서버에 제출하라고 만든 것이 아니며, aud가 API가 아니라 클라이언트를 가리키고 있어 자원 서버 입장에서는 대상이 어긋난 토큰입니다.

둘째, 액세스 토큰을 파싱해서 사용자 정보를 꺼내 쓰는 것. 액세스 토큰이 JWT라는 보장은 없습니다. 불투명한 문자열이어도 스펙상 정상이고, 공급자가 형식을 바꿔도 클라이언트는 항의할 근거가 없습니다. 사용자 정보가 필요하면 ID 토큰의 클레임이나 OIDC Core §5.3의 UserInfo 엔드포인트를 쓰는 것이 계약에 맞습니다.


6. JWT: 디코딩은 검증이 아니다

RFC 7519가 정의하는 JWT는 JWS(서명 또는 MAC으로 보호) 이거나 JWE(암호화) 입니다. 여기서 출발해야 오해가 정리됩니다.

6-1. base64url은 인코딩이지 보호가 아니다

JWT의 페이로드는 누구나 읽습니다. 암호화가 아니라 인코딩이기 때문입니다. JWT 페이로드에 민감정보를 넣으면 그것은 공개한 것과 같습니다. 주민등록번호, 내부 시스템 식별자, 권한 정책의 상세 내용 같은 것을 넣지 마세요.

6-2. 디코더가 내용을 보여준다고 그 토큰이 유효한 것은 아니다

리뷰나 장애 대응 중에 실제로 벌어지는 일입니다. 디코더에 붙여 넣어 subexp가 그럴듯하니 통과라고 판단하는 것. 디코딩은 서명 검증과 아무 관계가 없습니다. 이 블로그의 JWT 디코더도 서명을 검증하지 않는다고 명시하고 있습니다. 디코더는 내용을 읽는 도구이지 진위를 판정하는 도구가 아닙니다.

6-3. alg: none

RFC 7519 §6은 alg 값이 none이고 서명이 빈 문자열인 비보호 JWT를 정의합니다. 스펙은 이것을 보안이 외부 수단으로 제공되는 경우에 한해 허용합니다. 문제는 검증 측이 이 조건을 잊고 토큰이 말하는 대로 따라갈 때입니다. 공격자가 서명을 지우고 algnone으로 바꿔 보내면, 그 서버에서는 인증이 사라집니다.

6-4. 알고리즘 혼동(algorithm confusion)

더 교묘한 쪽입니다. 서버가 RS256(공개키 서명)으로 발급하는데, 검증 함수가 토큰의 alg 헤더를 읽어 그대로 사용한다고 해 봅시다. 공격자가 alg를 HS256으로 바꾸고 서버의 공개키를 HMAC 비밀키로 삼아 서명하면, 검증 코드는 같은 공개키로 HMAC을 계산해 일치 판정을 내립니다. 공개키는 공개된 값이므로 누구나 토큰을 위조할 수 있게 됩니다.

RFC 8725(BCP 225)는 이 문제를 정면으로 다룹니다.

  • §3.1 "라이브러리는 호출자가 지원 알고리즘 집합을 지정할 수 있게 해야 하며(MUST), 암호 연산 시 그 외의 알고리즘을 사용해서는 안 된다(MUST NOT)."
  • §3.1 "라이브러리는 alg 또는 enc 헤더가 실제 암호 연산에 사용되는 알고리즘과 같은 것을 지정하는지 보장해야 한다(MUST)."
  • §3.1 "각 키는 정확히 하나의 알고리즘과 함께 사용되어야 하며(MUST), 이는 암호 연산 수행 시 확인되어야 한다(MUST)."

한 문장으로 줄이면 이렇습니다. 토큰이 스스로 신고한 알고리즘을 믿지 마세요. 검증 알고리즘은 애플리케이션이 토큰 바깥에서 고정해야 합니다.

6-5. 무엇을 검사할지는 스펙이 정해 주지 않는다

RFC 7519에서 iss, sub, aud, exp, nbf, iat, jti는 전부 OPTIONAL입니다. 이 사실의 의미는 종종 반대로 읽힙니다. 선택적이라는 것은 "검사하지 않아도 된다"가 아니라, 무엇을 반드시 검사할지는 스펙이 아니라 애플리케이션이 결정해야 한다는 뜻입니다. "라이브러리가 알아서 검증해 주겠지"라는 기대가 틀리는 지점이 여기입니다. 기본값으로 만료만 보고 발급자와 대상을 보지 않는 라이브러리 설정은 드물지 않습니다.

최소한 이 정도는 애플리케이션이 명시적으로 결정하고 코드에 남겨야 합니다.

[ ] 서명 검증에 사용할 알고리즘을 코드에서 고정했는가 (토큰 헤더를 읽지 않고)
[ ] 검증 키의 출처가 고정되어 있는가 (JWKS URL, 키 회전 처리 포함)
[ ] iss 가 기대하는 발급자인가
[ ] aud 가 나를 가리키는가
[ ] exp / nbf 를 검사하는가, 허용 시계 오차는 몇 초인가
[ ] 필요한 경우 jti 로 재사용을 막는가

6-6. 취소는 여전히 어렵다

서명이 유효하고 만료 전이면 그 토큰은 유효합니다. 그것이 무상태 검증의 장점이자 대가입니다. 로그아웃, 권한 회수, 계정 정지를 즉시 반영하려면 결국 서버 측 상태(차단 목록, 토큰 버전, 세션 조회)가 필요하고, 그 순간 "무상태"라는 이점의 상당 부분은 사라집니다.

여기에 정답은 없습니다. 축은 액세스 토큰 수명입니다. 짧게 하면 취소 지연이 줄지만 인가 서버 트래픽과 장애 결합도가 올라갑니다. 길게 하면 반대입니다. 이 선택은 조직이 감내할 수 있는 "권한 회수 지연"을 몇 분으로 볼 것인가의 문제이며, 그 숫자를 정하고 적는 것이 설계입니다.


7. 토큰을 어디에 둘 것인가 — 그리고 세션을 어떻게 운영할 것인가

여기서 "정답은 하나"라고 말하는 글을 조심하세요. 양쪽 모두 비용이 있고, 비용의 종류가 다릅니다.

7-1. 웹 스토리지(localStorage / sessionStorage)

  • 약점: 같은 오리진에서 실행되는 모든 JavaScript가 읽습니다. OWASP 세션 관리 치트시트는 "인증 토큰, 세션 ID, JWT, 리프레시 토큰, 그 밖의 어떤 자격 증명도 localStoragesessionStorage에 저장하지 말 것"을 명시하며, 이유로 "단 하나의 XSS 취약점이 모든 토큰을 노출시킨다"를 듭니다.
  • 강점: 브라우저가 자동으로 붙이지 않으므로 CSRF 표면이 구조적으로 작습니다. 크로스 오리진 API 호출에 헤더로 실어 보내기도 단순합니다.

7-2. 쿠키(HttpOnly, Secure, SameSite)

  • 강점: HttpOnly면 JavaScript가 값을 읽지 못합니다. Secure는 OWASP 표현으로 "중간자 공격을 통한 세션 ID 노출을 막기 위해 필수"입니다.
  • 약점: 브라우저가 요청에 자동으로 붙이므로 CSRF 표면이 생깁니다. OWASP 문서도 이 점을 함께 적습니다. "XSS 공격이 CSRF 공격과 결합되면, 웹 애플리케이션으로 전송되는 요청에는 세션 쿠키가 포함된다. 브라우저는 요청을 보낼 때 항상 쿠키를 포함하기 때문이다." 그래서 세션 쿠키는 SameSite=Strict(권장) 또는 SameSite=Lax를 명시해야 하고, SameSite=NoneSecure 없이 절대 쓰지 않습니다.

7-3. 이 교환을 정직하게 요약하면

쿠키는 토큰 탈취를 어렵게 만들고 요청 위조를 쉽게 만듭니다. 웹 스토리지는 그 반대입니다. 그리고 결정적으로, XSS가 존재하면 어느 쪽도 안전하지 않습니다. 쿠키를 읽지 못해도 공격자는 그 브라우저에서 인증된 요청을 그냥 보낼 수 있기 때문입니다. 저장 위치 선택은 XSS 대응을 대체하지 않습니다.

OWASP처럼 쿠키를 권하는 문서가 다수이고 그 근거도 분명합니다. 다만 그 권고에는 전제가 붙습니다.

  • 프런트엔드와 API가 쿠키를 공유할 수 있는 사이트 구성인가
  • CSRF 방어(SameSite, CSRF 토큰, Origin/Referer 검증)를 함께 구현하는가
  • 서드파티 컨텍스트나 네이티브 앱처럼 쿠키가 잘 맞지 않는 배치는 아닌가

전제가 다르면 결론도 달라져야 합니다. 전제를 적지 않은 보안 권고는 다음 사람에게 위험합니다.

7-4. 세션을 운영한다는 것

토큰이든 세션 ID든, 운영 규칙은 비슷합니다. OWASP 세션 관리 치트시트 기준으로 최소한 이 정도입니다.

  • 엔트로피: 세션 식별자는 최소 64비트 엔트로피(16진수 표기로 16자 이상)를 가져야 합니다. 값의 일부가 고정되거나 예측 가능하면 유효 엔트로피는 그만큼 줄어듭니다.
  • 유휴 타임아웃과 절대 타임아웃: 활동이 없을 때 만료시키는 것과, 활동과 무관하게 생성 시점부터 최대 수명을 강제하는 것은 서로 다른 장치입니다. 둘 다 필요합니다. 문서가 제시하는 감각은 고가치 애플리케이션 2~5분, 위험이 낮은 쪽 15~30분입니다.
  • 세션 고정 방어: "세션 ID는 연관된 사용자 세션 내에서 권한 수준이 변경된 후 반드시 갱신되거나 재생성되어야 한다." 로그인 성공 직후가 대표적인 시점입니다.

8. 인가는 어디서 결정되는가

모델 이야기부터 하면 논의가 겉돕니다. 순서를 바꿔 보겠습니다.

8-1. 모델은 세 가지 정도로 충분하다

  • RBAC: 역할에 권한을 묶습니다. 역할 수가 관리 가능할 때 잘 맞습니다.
  • ABAC: 속성(부서, 지역, 시간, 등급)으로 판단합니다. 역할 폭증은 피하지만 규칙 검증과 디버깅이 어려워집니다.
  • ReBAC: "이 문서의 소유자와 같은 팀인가"처럼 관계로 판단합니다. 공유·협업 모델에 맞고 그래프 조회 비용을 냅니다.

셋은 배타적이지 않습니다. 대개 RBAC로 큰 틀을 잡고 특정 지점에서 속성이나 관계를 섞습니다.

8-2. 진짜 문제는 결정 지점이다

인가 실패의 상당수는 모델을 잘못 골라서가 아니라, 판정이 일어나는 위치를 잘못 골라서 생깁니다.

클라이언트 → [API 게이트웨이] → [서비스] → [데이터 접근 계층] → DB
                   ↑                ↑              ↑
              인증 + 조기 차단   업무 규칙 판정   객체 소유권 판정
  • 게이트웨이에서만 판정하면, 게이트웨이를 거치지 않는 내부 호출·배치·관리 도구가 그대로 우회합니다.
  • 서비스마다 각자 판정하면, 같은 규칙이 여러 곳에 흩어져 한쪽만 고쳐지는 사고가 납니다.

실무적인 규칙은 이렇습니다. 인가 판정은 데이터에 가장 가까운 곳에서 최소 한 번은 반드시 일어나야 합니다. 앞단의 판정은 조기 차단(성능, 사용자 경험, 로그 노이즈 감소)을 위한 것이지 신뢰의 근거가 아닙니다.

8-3. 가장 자주 빠지는 검사

객체 수준 인가입니다. 인증은 통과했고 역할도 맞는데, "이 사용자가 이 특정 리소스에 대한 권한이 있는가"를 확인하지 않는 경우입니다. 경로의 식별자만 바꿔서 남의 데이터를 읽는 사고가 여기서 나옵니다. 목록 조회는 소유자 조건으로 필터링하면서 단건 조회에서는 빠뜨리는 패턴이 특히 흔합니다.

그리고 판정은 닫히는 방향으로 실패해야 합니다. 권한 조회가 타임아웃되었을 때 기본값이 허용이면, 인가 시스템의 장애가 곧 전면 권한 개방이 됩니다.

거부 이유를 얼마나 자세히 알려줄지도 결정 사항입니다. 자세할수록 디버깅은 쉬워지고 정보는 더 샙니다. 내부 관리 도구와 공개 API에서 같은 정책을 쓸 이유는 없습니다.


9. 직접 만들 것인가 — 이 결정의 대가

마지막으로, 가장 자주 얼버무려지는 질문입니다.

인증을 직접 구현하는 것은 금지된 일이 아닙니다. 다만 비용이 큰 결정이고, 그 비용은 첫 릴리스가 아니라 그 이후에 청구됩니다. 이 문장을 회의록에 그대로 적어 두는 편이 좋습니다.

9-1. 직접 만들면 함께 떠안는 것들

로그인 화면과 비밀번호 검사는 이 목록의 첫 항목일 뿐입니다.

  • 비밀번호 저장(적절한 해시 함수와 파라미터, 그리고 그 파라미터의 주기적 갱신)
  • 계정 복구 흐름 — 통계적으로 인증 자체보다 여기가 더 자주 뚫립니다
  • 사용자 열거 방지: OWASP는 응답을 "일반적인 방식으로" 할 것을 요구하며, Login failed; Invalid user ID or password 같은 단일 메시지를 예로 듭니다. HTTP 상태 코드까지 동일해야 합니다. 문서는 "HTTP 응답 코드가 달라지면 계정의 유효 여부가 새어 나갈 수 있다"고 지적합니다
  • 잠금 정책: 실패 카운터는 "출발지 IP가 아니라 계정 자체에" 연결해야 하고, 1초에서 시작해 실패마다 두 배로 늘리는 지수적 잠금이 권장됩니다. 그리고 잠금 중에도 비밀번호 복구 경로는 열려 있어야 합니다. 아니면 잠금 자체가 서비스 거부 수단이 됩니다
  • MFA: OWASP는 "다중 인증은 비밀번호 관련 공격 대부분에 대한 단연 최선의 방어"라고 씁니다
  • 세션 무효화, 동시 세션 정책, 기기 관리, 감사 로그, 침해 시 전수 로그아웃

9-2. 위임하면 비용이 사라지는 것이 아니라 종류가 바뀐다

  • 공급자 장애가 곧 로그인 장애가 됩니다. 이 결합을 SLO에 반영해야 합니다
  • 계정 이전과 공급자 교체가 어려워집니다. 사용자 식별자를 공급자의 sub에 직접 묶으면 이전 시점에 정체성이 끊깁니다
  • 사용자 수 기반 과금, 데이터 저장 위치, 감사 요구사항이 새로 들어옵니다

9-3. 판단에 실제로 쓰이는 축

  • 침해 시 피해 규모(결제·의료·미성년자 데이터가 있는가)
  • 규제가 특정 방식을 요구하는가
  • 팀에 이 영역을 지속적으로 관리할 사람이 있는가 — 구축이 아니라 유지가 기준입니다
  • 요구되는 인증 방식의 폭(기업 SSO, SAML, 패스키, 소셜)

어느 쪽을 고르든, 고른 이유와 전제를 문서에 남기세요. 2년 뒤 이 결정을 되돌아볼 사람에게 필요한 것은 결론이 아니라 그때의 전제입니다. 그것이 이 시리즈 1편에서 다룬 설계 문서가 하는 일입니다.


퀴즈: 실력을 확인해 보세요

퀴즈 1: 모바일 앱 리뷰에서 인가 요청에 response_type=token을 쓰고 리다이렉트 프래그먼트로 액세스 토큰을 받는 코드를 봤습니다. 무엇이 문제이고 무엇으로 바꿔야 하나요?

정답: 암묵적 그랜트입니다. RFC 9700 §2.1.2가 SHOULD NOT으로 규정했으며, 인가 코드 그랜트 + PKCE로 바꿔야 합니다. 모바일 앱은 공개 클라이언트이므로 PKCE는 MUST입니다.

설명: 문제는 "구식이라서"가 아닙니다. 액세스 토큰이 인가 응답을 타고 흐르면서 브라우저 히스토리·리퍼러·로그로 유출될 수 있고, 인가 응답으로 발급된 토큰을 특정 클라이언트에 묶는 표준 발신자 제약 방법이 없습니다. 바꿀 때 클라이언트만 PKCE를 붙이고 끝내면 안 됩니다. 인가 서버가 PKCE 다운그레이드를 막는지, 즉 code_challenge 없이 시작한 요청에 code_verifier가 담긴 토큰 요청을 거부하는지도 확인해야 합니다.

퀴즈 2: 장애 대응 중 동료가 "토큰을 디코더에 넣어 보니 sub도 맞고 exp도 안 지났으니 이 요청은 정상"이라고 말합니다. 무엇이 잘못됐나요?

정답: 디코딩은 검증이 아닙니다. 서명을 확인하지 않았으므로 그 토큰이 우리 서버가 발급한 것인지 알 수 없습니다.

설명: JWT의 페이로드는 base64url 인코딩일 뿐이라 누구나 원하는 값으로 만들어 넣을 수 있습니다. 이 블로그의 JWT 디코더를 포함해 대부분의 디코더는 서명을 검증하지 않는다고 명시하고 있습니다. 판단에 필요한 것은 페이로드의 그럴듯함이 아니라 서명 검증 통과 여부, 그리고 issaud가 기대값과 일치하는지입니다. 덧붙여, 디코더에 운영 토큰을 붙여 넣는 행위 자체가 자격 증명을 외부 도구에 노출하는 일입니다.

퀴즈 3: 서버는 RS256으로 JWT를 발급합니다. 검증 코드가 토큰의 alg 헤더를 읽어 그 알고리즘으로 검증합니다. 어떤 공격이 가능한가요?

정답: 알고리즘 혼동 공격입니다. 공격자가 alg를 HS256으로 바꾸고 서버의 공개키를 HMAC 비밀키로 사용해 서명하면, 검증 코드는 같은 공개키로 HMAC을 계산해 일치 판정을 내립니다.

설명: 공개키는 이름 그대로 공개된 값이므로 누구나 유효한 토큰을 위조할 수 있게 됩니다. RFC 8725 §3.1은 라이브러리가 호출자로 하여금 지원 알고리즘 집합을 지정하게 해야 하고(MUST) 그 외의 알고리즘을 써서는 안 되며(MUST NOT), 각 키는 정확히 하나의 알고리즘과 함께 사용되어야 한다(MUST)고 규정합니다. 원칙은 하나입니다. 검증 알고리즘은 토큰이 아니라 애플리케이션이 정합니다. 같은 이유로 alg: none을 받아들이는 설정도 함께 점검해야 합니다.

퀴즈 4: 팀이 토큰을 localStorage에서 HttpOnly 쿠키로 옮겼습니다. "이제 XSS는 해결됐다"는 말이 왜 틀렸나요?

정답: 두 가지입니다. 첫째, HttpOnly는 토큰 값을 읽는 것을 막을 뿐 XSS 자체를 막지 않습니다. 공격자는 그 브라우저에서 인증된 요청을 그냥 보낼 수 있습니다. 둘째, 쿠키로 옮기면서 CSRF 표면이 새로 생겼습니다.

설명: 저장 위치 변경은 위험의 종류를 바꾸는 것이지 없애는 것이 아닙니다. 쿠키는 토큰 탈취를 어렵게 만드는 대신 브라우저가 요청에 자동으로 붙이므로 요청 위조 경로를 엽니다. OWASP도 XSS와 CSRF가 결합하면 세션 쿠키가 요청에 포함된다는 점을 함께 적어 두고 있습니다. 이전과 함께 SameSite 설정, CSRF 방어, 그리고 무엇보다 출력 인코딩과 CSP 같은 XSS 대응이 같이 가야 합니다.

퀴즈 5: API 게이트웨이가 JWT 서명과 만료를 검증하고, 통과한 요청만 주문 서비스로 넘깁니다. 주문 조회 API에서 다른 사용자의 주문 ID를 넣으면 데이터가 그대로 나옵니다. 무엇이 빠졌나요?

정답: 객체 수준 인가입니다. 게이트웨이는 "이 요청자가 인증된 사용자인가"만 확인했고, "이 사용자가 이 특정 주문의 소유자인가"는 아무도 확인하지 않았습니다.

설명: 인증과 인가를 같은 지점에서 끝냈다고 착각할 때 나오는 전형적인 사고입니다. 게이트웨이의 판정은 조기 차단용이지 신뢰의 근거가 아닙니다. 소유권 판정은 데이터에 가장 가까운 곳, 즉 주문을 실제로 조회하는 계층에서 이루어져야 합니다. 목록 조회는 소유자 조건으로 필터링하면서 단건 조회에서 빠뜨리는 패턴이 특히 흔하므로, 단건 조회 경로를 따로 점검하는 것이 좋습니다.

퀴즈 6: 프런트엔드가 OIDC 로그인 후 받은 ID 토큰을 그대로 API 호출의 Bearer 토큰으로 보내고 있습니다. 왜 문제인가요?

정답: ID 토큰은 클라이언트가 인증 사실을 확인하기 위한 토큰이지 자원 서버에 제출하는 토큰이 아닙니다. aud가 API가 아니라 클라이언트의 client_id를 가리키므로 자원 서버 입장에서는 대상이 어긋난 토큰입니다.

설명: 자원 접근에는 액세스 토큰을 써야 합니다. 자원 서버가 이 상황을 그대로 받아들인다면, 그 서버는 aud 검사를 하지 않고 있다는 뜻이기도 합니다. 이는 다른 클라이언트를 위해 발급된 토큰까지 받아들이는 더 넓은 문제로 이어집니다. 반대 방향의 실수도 함께 보세요. 액세스 토큰을 파싱해 사용자 정보를 꺼내 쓰는 것 역시 계약 위반입니다. 액세스 토큰은 JWT일 의무가 없습니다.


마치며

이 글에서 반복한 문장을 다시 모으면 이렇습니다.

  • OAuth 2.0은 인가 프레임워크이고, 인증은 OIDC가 ID 토큰으로 채웁니다
  • 암묵적 그랜트는 SHOULD NOT, ROPC는 MUST NOT이며, 대체는 인가 코드 + PKCE입니다
  • 디코딩은 검증이 아니고, 토큰이 신고한 알고리즘은 근거가 아닙니다
  • 저장 위치 선택은 위험을 없애지 않고 종류를 바꿉니다. XSS가 있으면 어느 쪽도 안전하지 않습니다
  • 인가 판정은 데이터에 가장 가까운 곳에서 최소 한 번은 반드시 일어나야 합니다

마지막으로 가장 중요한 것. 전제 없는 보안 권고를 쓰지도, 받지도 마세요. "이렇게 하면 안전합니다"라는 문장은 거의 언제나 "이런 조건에서는"이 앞에 생략되어 있습니다. 생략된 조건이 우리 환경에서 성립하지 않을 때, 그 문장은 안전하다는 착각만 남깁니다. 조건을 적는 습관 하나가 이 영역에서 가장 값싸고 가장 효과적인 방어입니다.


참고 자료

  • RFC 6749 — The OAuth 2.0 Authorization Framework — 네 역할의 정의(§1.1), 네 가지 그랜트(§1.3), 기밀/공개 클라이언트 구분(§2.1), 그리고 문서가 스스로를 인가 프레임워크로 규정한다는 점. 2026-08-15 확인.
  • RFC 9700 — Best Current Practice for OAuth 2.0 Security — 암묵적 그랜트 SHOULD NOT(§2.1.2), ROPC MUST NOT(§2.4), PKCE 요구사항과 다운그레이드 방지(§2.1.1), 리다이렉트 URI 정확 일치(§4.1.3), 리프레시 토큰 회전·발신자 제약(§2.2.2). 2025년 1월, BCP 240. 2026-08-15 확인.
  • RFC 7636 — Proof Key for Code Exchange by OAuth Public Clients — 인가 코드 가로채기 공격, code_verifier 길이(43~128자)와 생성 권장, S256 대 plain. 2026-08-15 확인.
  • RFC 7519 — JSON Web Token — JWS/JWE 구분, 비보호 JWT와 alg: none의 조건(§6), 등록 클레임이 전부 OPTIONAL이라는 사실. 2026-08-15 확인.
  • RFC 8725 — JSON Web Token Best Current Practices — 알고리즘 혼동, 지원 알고리즘 집합 지정 요구, alg 헤더와 실제 연산 일치 요구, 키당 단일 알고리즘 요구(§3.1). BCP 225. 2026-08-15 확인.
  • OpenID Connect Core 1.0 — "OAuth 2.0 위의 단순한 아이덴티티 계층"이라는 초록 문장, ID 토큰 정의(§2)와 필수 클레임, UserInfo 엔드포인트(§5.3). 2026-08-15 확인.
  • RFC 9110 — HTTP Semantics — 401과 403의 정의 및 인증/인가 구분(§15.5.2, §15.5.4). 2026-08-15 확인.
  • OWASP Authentication Cheat Sheet — 일반화된 실패 응답과 HTTP 상태 코드 일치, 계정 기준 실패 카운터와 지수적 잠금, 잠금 중 복구 경로 유지, MFA 권고. 2026-08-15 확인.
  • OWASP Session Management Cheat Sheet — 웹 스토리지 저장 금지 권고와 그 근거, 쿠키 속성별 방어 범위와 XSS·CSRF 결합, 64비트 엔트로피, 유휴/절대 타임아웃, 권한 변경 후 세션 재생성. 2026-08-15 확인.
  • 인가 결정 지점(게이트웨이·서비스·데이터 계층)의 배치 원칙과 직접 구현 여부 판단 축은 위 문서들에 그대로 나오는 항목이 아니라, 그 요구사항들을 바탕으로 이 글에서 정리한 것입니다.

이어서 읽기

완전 가이드 시리즈

현재 단락 (1/169)

이 블로그에는 이미 [OAuth 2.0 완전 정복](/blog/architecture/2026-03-03-oauth-deep-dive)과 [JWT와 세션, 무엇을 언제](/blog...

작성 글자: 0원문 글자: 13,814작성 단락: 0/169