Split View: 인증과 인가 완전 가이드: 스펙 원문으로 짚는 열 가지 오해
인증과 인가 완전 가이드: 스펙 원문으로 짚는 열 가지 오해
- 들어가며
- 1. 인증과 인가는 다른 문제다
- 2. OAuth 2.0은 인가 프레임워크다 — OIDC가 그 위에 얹는 것
- 3. 권장에서 빠진 플로우들과 그 대체
- 4. PKCE — 무엇을 막고 무엇을 못 막는가
- 5. 세 가지 토큰을 구분하기
- 6. JWT: 디코딩은 검증이 아니다
- 7. 토큰을 어디에 둘 것인가 — 그리고 세션을 어떻게 운영할 것인가
- 8. 인가는 어디서 결정되는가
- 9. 직접 만들 것인가 — 이 결정의 대가
- 퀴즈: 실력을 확인해 보세요
- 마치며
- 참고 자료
- 이어서 읽기
들어가며
이 블로그에는 이미 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_method는 plain과 S256 두 가지인데, 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. 디코더가 내용을 보여준다고 그 토큰이 유효한 것은 아니다
리뷰나 장애 대응 중에 실제로 벌어지는 일입니다. 디코더에 붙여 넣어 sub와 exp가 그럴듯하니 통과라고 판단하는 것. 디코딩은 서명 검증과 아무 관계가 없습니다. 이 블로그의 JWT 디코더도 서명을 검증하지 않는다고 명시하고 있습니다. 디코더는 내용을 읽는 도구이지 진위를 판정하는 도구가 아닙니다.
6-3. alg: none
RFC 7519 §6은 alg 값이 none이고 서명이 빈 문자열인 비보호 JWT를 정의합니다. 스펙은 이것을 보안이 외부 수단으로 제공되는 경우에 한해 허용합니다. 문제는 검증 측이 이 조건을 잊고 토큰이 말하는 대로 따라갈 때입니다. 공격자가 서명을 지우고 alg를 none으로 바꿔 보내면, 그 서버에서는 인증이 사라집니다.
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, 리프레시 토큰, 그 밖의 어떤 자격 증명도
localStorage나sessionStorage에 저장하지 말 것"을 명시하며, 이유로 "단 하나의 XSS 취약점이 모든 토큰을 노출시킨다"를 듭니다. - 강점: 브라우저가 자동으로 붙이지 않으므로 CSRF 표면이 구조적으로 작습니다. 크로스 오리진 API 호출에 헤더로 실어 보내기도 단순합니다.
7-2. 쿠키(HttpOnly, Secure, SameSite)
- 강점:
HttpOnly면 JavaScript가 값을 읽지 못합니다.Secure는 OWASP 표현으로 "중간자 공격을 통한 세션 ID 노출을 막기 위해 필수"입니다. - 약점: 브라우저가 요청에 자동으로 붙이므로 CSRF 표면이 생깁니다. OWASP 문서도 이 점을 함께 적습니다. "XSS 공격이 CSRF 공격과 결합되면, 웹 애플리케이션으로 전송되는 요청에는 세션 쿠키가 포함된다. 브라우저는 요청을 보낼 때 항상 쿠키를 포함하기 때문이다." 그래서 세션 쿠키는
SameSite=Strict(권장) 또는SameSite=Lax를 명시해야 하고,SameSite=None은Secure없이 절대 쓰지 않습니다.
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 디코더를 포함해 대부분의 디코더는 서명을 검증하지 않는다고 명시하고 있습니다. 판단에 필요한 것은 페이로드의 그럴듯함이 아니라 서명 검증 통과 여부, 그리고 iss와 aud가 기대값과 일치하는지입니다. 덧붙여, 디코더에 운영 토큰을 붙여 넣는 행위 자체가 자격 증명을 외부 도구에 노출하는 일입니다.
퀴즈 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 확인.
- 인가 결정 지점(게이트웨이·서비스·데이터 계층)의 배치 원칙과 직접 구현 여부 판단 축은 위 문서들에 그대로 나오는 항목이 아니라, 그 요구사항들을 바탕으로 이 글에서 정리한 것입니다.
이어서 읽기
- 이 블로그의 관련 글: OAuth 2.0 완전 정복 — 인증과 인가의 모든 것
- 이 블로그의 관련 글: JWT와 세션, 무엇을 언제 — 상태를 어디에 둘 것인가
- 관련 도구: JWT 디코더 — 토큰의 내용을 읽는 도구입니다. 서명을 검증하지 않으므로 진위 판정에는 쓸 수 없습니다
- 관련 도구: 인증 플로우 시각화, 인증 보안 실습, HTTP 상태 코드
완전 가이드 시리즈
The Complete Guide to Authentication and Authorization: Ten Misconceptions, Corrected From the Specs
- Introduction
- 1. Authentication and authorization are different problems
- 2. OAuth 2.0 is an authorization framework — and what OIDC adds on top
- 3. The flows that fell out of the recommendations, and what replaced them
- 4. PKCE — what it stops and what it does not
- 5. Telling the three tokens apart
- 6. JWT: decoding is not verifying
- 7. Where to put the token — and how to run the session
- 8. Where the authorization decision is made
- 9. Should you build it yourself — and what that decision costs
- Quiz: check your understanding
- Closing
- References
- Further reading
Introduction
This blog already has OAuth 2.0 Mastery, JWT or Sessions, and When, and a per-framework SSO series. Those explain how the protocols work. This guide takes a different angle: it identifies exactly where the recurring misconceptions come from, and corrects each one against the sentence in the spec that settles it.
One thing to state before we start. This is not a recipe. Security advice only means something when its assumptions come with it. "Put it in a cookie", "use JWTs" — the moment you drop the assumptions, those sentences become wrong advice. So every recommendation here comes with the condition under which it holds. When the condition changes, the conclusion has to change too.
1. Authentication and authorization are different problems
- Authentication: establishing who sent the request
- Authorization: deciding whether that established party may perform this action
This distinction is not explanatory garnish. HTTP itself separates the two states into different status codes. RFC 9110 §15.5.2 defines 401 as "the request has not been applied because it lacks valid authentication credentials for the target resource", and §15.5.4 defines 403 as "the server understood the request but refuses to fulfill it". 401 is an authentication problem; 403 is an authorization problem.
The fact that 401 is named Unauthorized is close to a historical accident. The name looks like authorization; the meaning is authentication.
This matters in practice because client behaviour depends on it.
- Return 403 to a user who is not logged in, and the client loses its basis for starting a re-login flow.
- Return 401 to a user who lacks permission, and the client logs in again, gets another 401, and loops forever.
There is one deliberate exception. When the existence of a resource must itself be hidden, some systems return 404 instead of 403. If putting another user's document ID into the URL returns 403, that leaks the fact that the ID exists. This is a trade-off between conveying precise meaning and minimising information disclosure, and neither side generalises. Decide, and write the decision down.
2. OAuth 2.0 is an authorization framework — and what OIDC adds on top
This is the most frequently repeated misconception. If you start designing with OAuth 2.0 understood as a login protocol, every decision downstream is skewed.
RFC 6749 is titled The OAuth 2.0 Authorization Framework. The document defines itself as an authorization framework and does not describe itself as an authentication protocol. The four roles it defines are written in the language of delegated access.
| Role | Definition in RFC 6749 §1.1 |
|---|---|
| Resource owner | An entity capable of granting access to a protected resource; the end-user if human |
| Client | An application making protected resource requests on behalf of the resource owner |
| Authorization server | The server issuing access tokens after authenticating the owner and obtaining consent |
| Resource server | The server hosting protected resources and accepting access tokens |
An access token is a certificate saying "this client may access this resource to this extent". It is not a proof saying "this is who the person is". Those are two different sentences.
That is why using an access token directly as evidence of login is dangerous. An access token carries no standard mechanism for a client to confirm that the token was issued to it. A token minted for a different application can be presented to your server, and you may have no way to tell.
OIDC exists to fill exactly that gap. The first sentence of the OpenID Connect Core 1.0 abstract reads: "OpenID Connect 1.0 is a simple identity layer on top of the OAuth 2.0 protocol." §2 defines the ID Token as "a security token that contains Claims about the Authentication of an End-User by an Authorization Server", formatted as a JWT.
The ID Token's required claims are iss, sub, aud, exp, and iat, and aud must contain that client's client_id. That aud check is the mechanism the access token does not have. An ID Token that does not carry your client_id was not issued for you, so you reject it.
To summarise:
- OAuth 2.0 alone gives you delegated access, with no guarantees as an authentication protocol
- OIDC gives you an ID Token — a verifiable authentication result
- Most of what gets called "social login" is either OIDC or a non-standard convention each provider layered on top of OAuth
3. The flows that fell out of the recommendations, and what replaced them
OAuth 2.0's security guidance did not stop at RFC 6749 in 2012. RFC 9700 (January 2025, BCP 240) is the current security best current practice, and it updates RFC 6749, 6750, and 6819. Two grant types changed status there.
3-1. Implicit grant — SHOULD NOT
RFC 9700 §2.1.2 states: "Clients SHOULD NOT use the implicit grant (response type token) or other response types issuing access tokens in the authorization response, unless access token injection in the authorization response is prevented."
There are two reasons. Access tokens travelling through the authorization response (fragments, redirects, browser history, referrers) are exposed to leakage and replay, and there is no standardized method for sender-constraining tokens issued in the authorization response to a specific client.
3-2. Resource owner password credentials (ROPC) — MUST NOT
§2.4 is blunter: "The resource owner password credentials grant … MUST NOT be used."
The reasons are that it insecurely exposes the resource owner's credentials to the client, widening the attack surface, and that it is not designed to work with two-factor authentication or authentication processes requiring multiple user interaction steps.
Do not skim past the difference between SHOULD NOT and MUST NOT. The implicit grant is conditionally prohibited; ROPC is prohibited unconditionally. When you cite the basis in a document, that difference carries real weight.
3-3. What replaced them
The current recommendation is the authorization code grant plus PKCE. RFC 9700 §2.1.1 nails it down in three sentences.
- "Public clients MUST use PKCE."
- "For confidential clients, the use of PKCE is RECOMMENDED."
- "Authorization servers MUST support PKCE."
The same section governs redirect URIs. Authorization servers must compare against pre-registered URIs using exact string matching, with the sole exception of variable port numbers for localhost redirects in native apps. Implementations that allow partial or prefix matching violate this requirement.
Refresh tokens are covered too. §2.2.2 requires that refresh tokens for public clients be sender-constrained or use refresh token rotation.
4. PKCE — what it stops and what it does not
What PKCE, defined in RFC 7636, stops is the authorization code interception attack: the authorization code is stolen not over the TLS channel between client and server but over an unprotected path, such as inter-application communication inside a device, and the attacker exchanges it for a token.
The mechanism is simple.
Client Authorization server
| |
|-- generate code_verifier (32 random octets) |
|-- code_challenge = BASE64URL(SHA256(verifier))|
| |
|--- auth request + code_challenge + S256 ---->|
| | (stores challenge)
|<-- authorization code -----------------------|
| |
|--- token request + code + code_verifier ---->|
| | SHA256(verifier) == challenge ?
|<-- access token -----------------------------|
The code_verifier is 43–128 characters, and the spec recommends generating 32 random octets and base64url-encoding them. code_challenge_method is either plain or S256; S256 is Mandatory To Implement on the server, and clients capable of S256 must use S256. plain survives only for constrained legacy environments.
What PKCE does not do must be equally clear.
- PKCE is not CSRF protection. You still need the
stateparameter (or OIDC'snonce). - PKCE does not protect an access token that has already been issued and then leaked.
- It is defeated if the authorization server permits a PKCE downgrade. RFC 9700 §2.1.1 requires (MUST) that a token request containing a
code_verifierbe accepted only if acode_challengewas present in the authorization request. Adding PKCE on the client alone does not finish the job.
5. Telling the three tokens apart
They are all called "tokens", so they get blurred together, but their consumers and lifetimes differ.
| Token | Who validates it | Where it is sent | May the client read it? |
|---|---|---|---|
| Access token | Resource server | The resource server (API) | No. Opaque is perfectly normal |
| Refresh token | Authorization server | The AS token endpoint | No |
| ID token | The client | Nowhere | Yes. That is what it exists for |
Two common mistakes follow from this.
First, using the ID Token as the Bearer token on API calls. The ID Token exists so the client can confirm that the user was authenticated. It was not built to be presented to a resource server, and its aud points at the client rather than the API, so from the resource server's perspective the audience is simply wrong.
Second, parsing the access token to extract user information. Nothing guarantees an access token is a JWT. An opaque string is spec-conformant, and if the provider changes the format the client has no standing to object. When you need user information, the ID Token's claims or the UserInfo endpoint in OIDC Core §5.3 is what the contract actually offers.
6. JWT: decoding is not verifying
RFC 7519 defines a JWT as either a JWS (protected by a signature or MAC) or a JWE (encrypted). Starting there clears up most of the confusion.
6-1. base64url is encoding, not protection
Anyone can read a JWT payload, because it is encoding rather than encryption. Putting sensitive data in a JWT payload is equivalent to publishing it. Keep national ID numbers, internal system identifiers, and the details of your authorization policy out of it.
6-2. A decoder showing you the contents does not make the token valid
This genuinely happens during reviews and incident response: someone pastes the token into a decoder, sees a plausible sub and exp, and calls it valid. Decoding has nothing to do with signature verification. This blog's own JWT decoder states that it does not verify signatures. A decoder is a tool for reading contents, not for judging authenticity.
6-3. alg: none
RFC 7519 §6 defines the unsecured JWT, with alg set to none and an empty signature. The spec permits this only when security is provided by external means. The problem arises when the verifying side forgets that condition and follows whatever the token says. If an attacker strips the signature and sets alg to none, authentication disappears on that server.
6-4. Algorithm confusion
This one is subtler. Suppose the server issues tokens signed with RS256 (public-key signature), but the verification function reads the token's alg header and uses it. An attacker changes alg to HS256 and signs the token using the server's public key as the HMAC secret. The verifying code then computes an HMAC with that same public key and declares a match. Because the public key is public, anyone can forge tokens.
RFC 8725 (BCP 225) addresses this head-on.
- §3.1 "Libraries MUST enable the caller to specify a supported set of algorithms and MUST NOT use any other algorithms when performing cryptographic operations."
- §3.1 "The library MUST ensure that the 'alg' or 'enc' header specifies the same algorithm that is used for the cryptographic operation."
- §3.1 "Each key MUST be used with exactly one algorithm, and this MUST be checked when the cryptographic operation is performed."
Reduced to one sentence: do not trust the algorithm the token declares about itself. The verification algorithm must be pinned by the application, outside the token.
6-5. The spec does not decide what you must check
In RFC 7519, iss, sub, aud, exp, nbf, iat, and jti are all OPTIONAL. That fact is often read backwards. Optional does not mean "you need not check them"; it means the application, not the spec, must decide what has to be checked. This is where "surely the library validates it" breaks down. Library defaults that check only expiry, and never issuer or audience, are not rare.
At minimum, decide these explicitly and leave the decision in the code.
[ ] Is the verification algorithm pinned in code (not read from the token header)?
[ ] Is the source of the verification key fixed (JWKS URL, key rotation handled)?
[ ] Is iss the issuer you expect?
[ ] Does aud point at you?
[ ] Are exp / nbf checked, and what clock skew do you allow?
[ ] Where needed, does jti prevent reuse?
6-6. Revocation is still hard
If the signature verifies and the token has not expired, it is valid. That is both the benefit and the price of stateless verification. Reflecting logout, permission withdrawal, or account suspension immediately eventually requires server-side state — a deny list, a token version, a session lookup — and at that moment much of the "stateless" advantage is gone.
There is no right answer here. The axis is access token lifetime. Shorter lifetimes reduce revocation lag but increase authorization-server traffic and failure coupling. Longer lifetimes do the reverse. The choice is really a question of how many minutes of permission-withdrawal lag your organisation can tolerate, and picking that number and writing it down is the design work.
7. Where to put the token — and how to run the session
Be wary of any article here that says there is one right answer. Both sides carry cost; the costs are simply of different kinds.
7-1. Web storage (localStorage / sessionStorage)
- Weakness: every piece of JavaScript running in the origin can read it. The OWASP Session Management Cheat Sheet explicitly says not to store "authentication tokens, session IDs, JWTs, refresh tokens, or any credential" in
localStorageorsessionStorage, on the grounds that "a single XSS vulnerability discloses every token". - Strength: the browser does not attach it automatically, so the CSRF surface is structurally smaller. Attaching it as a header on cross-origin API calls is also straightforward.
7-2. Cookies (HttpOnly, Secure, SameSite)
- Strength: with
HttpOnly, JavaScript cannot read the value. OWASP callsSecure"mandatory to prevent the disclosure of the session ID through MitM attacks". - Weakness: the browser attaches it to requests automatically, which creates a CSRF surface. OWASP records this alongside: "if an XSS attack is combined with a CSRF attack, the requests sent to the web application will include the session cookie, as the browser always includes the cookies when sending requests." Session cookies must therefore set
SameSite=Strict(preferred) orSameSite=Lax, andSameSite=Nonemust never be used withoutSecure.
7-3. An honest summary of the trade
Cookies make token theft harder and request forgery easier. Web storage does the reverse. And decisively: if you have XSS, neither is safe. Even when the attacker cannot read the cookie, they can simply issue authenticated requests from that browser. Choosing a storage location does not substitute for fixing XSS.
Most authorities, OWASP included, recommend cookies, and the reasoning is sound. But that recommendation carries assumptions.
- Is your site laid out so that the frontend and API can share cookies?
- Are you implementing CSRF defences alongside (SameSite, CSRF tokens, Origin/Referer checks)?
- Is this a third-party context or a native app, where cookies fit poorly?
When the assumptions differ, so must the conclusion. Security advice with its assumptions stripped out is dangerous to whoever reads it next.
7-4. What it means to operate a session
Whether it is a token or a session ID, the operating rules are similar. Per the OWASP Session Management Cheat Sheet, at minimum:
- Entropy: session identifiers must have at least 64 bits of entropy (16 or more hexadecimal characters). If any part of the value is fixed or predictable, the effective entropy drops accordingly.
- Idle timeout and absolute timeout: expiring on inactivity and enforcing a maximum lifetime from creation regardless of activity are two different mechanisms. You need both. The document's sense of scale is 2–5 minutes for high-value applications, 15–30 minutes for lower-risk ones.
- Session fixation defence: "The session ID must be renewed or regenerated by the web application after any privilege level change within the associated user session." Immediately after a successful login is the canonical moment.
8. Where the authorization decision is made
Leading with the models makes the discussion drift. Let us reverse the order.
8-1. Three models are usually enough
- RBAC: permissions attach to roles. Works when the number of roles stays manageable.
- ABAC: decisions come from attributes (department, region, time, tier). Avoids role explosion but makes rule verification and debugging harder.
- ReBAC: decisions come from relationships, such as "is this user on the same team as the document's owner". Fits sharing and collaboration models, and you pay for graph lookups.
The three are not exclusive. Most systems frame things with RBAC and mix in attributes or relationships at specific points.
8-2. The real problem is the decision point
A large share of authorization failures come not from choosing the wrong model but from choosing the wrong place for the decision.
Client -> [API gateway] -> [Service] -> [Data access layer] -> DB
| | |
authn + early reject business rules object ownership
- Decide only at the gateway, and internal calls, batch jobs, and admin tools that bypass the gateway bypass the check.
- Decide separately in every service, and the same rule scatters across many places, so one copy gets fixed and the others do not.
The working rule is this. The authorization decision must happen at least once as close to the data as possible. Decisions further forward exist for early rejection — latency, user experience, log noise — not as the basis for trust.
8-3. The check that gets skipped most often
Object-level authorization. Authentication passed and the role matches, but nobody verified whether this user has rights to this particular resource. Swapping an identifier in the path to read someone else's data comes from here. It is especially common to filter the list endpoint by owner and then forget the same condition on the single-item endpoint.
Decisions must also fail closed. If the default when a permission lookup times out is "allow", an outage in the authorization system becomes a blanket grant of access.
How much detail to give in a denial is also a decision. More detail makes debugging easier and leaks more. There is no reason for an internal admin tool and a public API to share one policy.
9. Should you build it yourself — and what that decision costs
Finally, the question that gets hedged most often.
Implementing authentication yourself is not forbidden. It is an expensive decision, and the cost is billed after the first release rather than before it. That sentence is worth writing into the meeting notes verbatim.
9-1. What you take on by building it
The login screen and the password check are only the first item on the list.
- Password storage (an appropriate hash function and parameters, and periodic updates to those parameters)
- Account recovery flows — statistically, this is breached more often than authentication itself
- User enumeration defence: OWASP requires responding "in a generic manner", offering
Login failed; Invalid user ID or passwordas the example. The HTTP status code must match too — the document notes that "the HTTP response code may differ which can leak information about whether the account is valid or not" - Lockout policy: the failure counter should be "associated with the account itself, rather than the source IP address", and exponential lockout starting at one second and doubling per failure is recommended. And password recovery must stay reachable while locked out, or lockout becomes a denial-of-service tool
- MFA: OWASP writes that "multi-factor authentication (MFA) is by far the best defense against the majority of password-related attacks"
- Session invalidation, concurrent-session policy, device management, audit logs, and mass logout on breach
9-2. Delegating does not remove cost — it changes its type
- A provider outage becomes a login outage. That coupling has to appear in your SLOs
- Account migration and provider replacement get harder. Bind your user identifier directly to the provider's
suband identity breaks at migration time - Per-user pricing, data residency, and audit requirements arrive as new constraints
9-3. The axes that actually decide it
- Blast radius of a breach (is there payment, medical, or minors' data?)
- Whether regulation mandates a particular approach
- Whether the team has someone to maintain this area continuously — maintenance, not construction, is the criterion
- The breadth of authentication methods required (enterprise SSO, SAML, passkeys, social)
Whichever you choose, write down the reason and the assumptions. What someone revisiting this decision in two years needs is not the conclusion but the assumptions that held at the time. That is the job of the design doc covered in part 1 of this series.
Quiz: check your understanding
Quiz 1: In a mobile app review you find code using response_type=token on the authorization request and receiving the access token in the redirect fragment. What is wrong, and what should replace it?
Answer: It is the implicit grant. RFC 9700 §2.1.2 marks it SHOULD NOT, and it should be replaced by the authorization code grant plus PKCE. A mobile app is a public client, so PKCE is a MUST.
Explanation: The problem is not that it is "old-fashioned". The access token travels through the authorization response and can leak via browser history, referrers, or logs, and there is no standard sender-constraining method to bind a token issued in the authorization response to a specific client. When you migrate, do not stop at adding PKCE on the client. Check that the authorization server blocks PKCE downgrade — that it rejects a token request carrying a code_verifier when the authorization request had no code_challenge.
Quiz 2: During an incident a colleague says "I pasted the token into a decoder, sub is right and exp has not passed, so this request is fine." What is wrong?
Answer: Decoding is not verifying. Without checking the signature, you cannot know whether your server issued that token.
Explanation: A JWT payload is base64url encoding, so anyone can construct one with whatever values they like. Most decoders, this blog's included, state that they do not verify signatures. What the judgement requires is not a plausible-looking payload but a passing signature check plus iss and aud matching your expectations. As an aside, pasting a production token into a decoder is itself exposing a credential to an external tool.
Quiz 3: The server issues JWTs with RS256. The verification code reads the token's alg header and verifies with that algorithm. What attack becomes possible?
Answer: Algorithm confusion. An attacker sets alg to HS256 and signs with the server's public key as the HMAC secret; the verification code then computes an HMAC with the same public key and declares a match.
Explanation: The public key is, by definition, public, so anyone can forge a valid token. RFC 8725 §3.1 requires that libraries let the caller specify a supported set of algorithms (MUST) and use no others (MUST NOT), and that each key be used with exactly one algorithm (MUST). The principle is single: the verification algorithm is chosen by the application, not by the token. For the same reason, check whether your configuration would accept alg: none.
Quiz 4: A team moved tokens from localStorage to HttpOnly cookies. Why is "XSS is solved now" wrong?
Answer: Two reasons. First, HttpOnly only prevents reading the token value; it does not stop XSS. The attacker can simply send authenticated requests from that browser. Second, moving to cookies introduced a new CSRF surface.
Explanation: Changing where you store it changes the type of risk, not its existence. Cookies make theft harder while opening a request-forgery path, because the browser attaches them automatically. OWASP records that when XSS and CSRF combine, the session cookie is included in the request. The move has to be accompanied by SameSite configuration, CSRF defences, and above all XSS mitigations such as output encoding and CSP.
Quiz 5: The API gateway verifies the JWT signature and expiry and forwards only passing requests to the order service. Putting another user's order ID into the order lookup API returns their data. What is missing?
Answer: Object-level authorization. The gateway confirmed only that the caller is an authenticated user; nobody confirmed that this user owns this particular order.
Explanation: This is the classic failure when authentication and authorization are assumed to be finished at the same point. The gateway's decision is for early rejection, not a basis for trust. Ownership must be decided as close to the data as possible — in the layer that actually loads the order. Because it is especially common to filter list queries by owner while forgetting the single-item path, audit the single-item path specifically.
Quiz 6: The frontend sends the ID Token it received after OIDC login as the Bearer token on API calls. Why is this a problem?
Answer: The ID Token exists for the client to confirm authentication; it is not a token to present to a resource server. Its aud points at the client's client_id rather than the API, so from the resource server's view the audience is wrong.
Explanation: Resource access should use the access token. If the resource server accepts this, it also means that server is not checking aud, which generalises to the broader problem of accepting tokens minted for other clients. Watch for the mirror-image mistake too: parsing an access token to extract user information is also a contract violation, because an access token is under no obligation to be a JWT.
Closing
Gathering the sentences this guide repeated:
- OAuth 2.0 is an authorization framework; authentication is what OIDC supplies via the ID Token
- The implicit grant is SHOULD NOT and ROPC is MUST NOT; the replacement is authorization code plus PKCE
- Decoding is not verifying, and the algorithm a token declares is not evidence
- Choosing a storage location changes the type of risk, not its existence. With XSS, neither option is safe
- The authorization decision must happen at least once as close to the data as possible
And most importantly: do not write, or accept, security advice without its assumptions. "Do this and you are safe" almost always has "under these conditions" silently removed from the front. When the removed condition does not hold in your environment, what the sentence leaves behind is only the feeling of safety. The habit of writing the conditions down is the cheapest and most effective defence in this area.
References
- RFC 6749 — The OAuth 2.0 Authorization Framework — the four roles (§1.1), the four grant types (§1.3), confidential vs public clients (§2.1), and the fact that the document defines itself as an authorization framework. Checked 2026-08-15.
- RFC 9700 — Best Current Practice for OAuth 2.0 Security — implicit grant SHOULD NOT (§2.1.2), ROPC MUST NOT (§2.4), PKCE requirements and downgrade prevention (§2.1.1), exact redirect URI matching (§4.1.3), refresh token rotation and sender-constraining (§2.2.2). January 2025, BCP 240. Checked 2026-08-15.
- RFC 7636 — Proof Key for Code Exchange by OAuth Public Clients — the authorization code interception attack, code_verifier length (43–128 characters) and generation guidance, S256 versus plain. Checked 2026-08-15.
- RFC 7519 — JSON Web Token — the JWS/JWE distinction, unsecured JWTs and the condition on
alg: none(§6), and the fact that every registered claim is OPTIONAL. Checked 2026-08-15. - RFC 8725 — JSON Web Token Best Current Practices — algorithm confusion, the requirement to specify a supported algorithm set, the requirement that the
algheader match the actual operation, and one algorithm per key (§3.1). BCP 225. Checked 2026-08-15. - OpenID Connect Core 1.0 — the "simple identity layer on top of the OAuth 2.0 protocol" sentence from the abstract, the ID Token definition (§2) and required claims, and the UserInfo endpoint (§5.3). Checked 2026-08-15.
- RFC 9110 — HTTP Semantics — the definitions of 401 and 403 and the authentication/authorization split (§15.5.2, §15.5.4). Checked 2026-08-15.
- OWASP Authentication Cheat Sheet — generic failure responses including matching HTTP status codes, account-based failure counters and exponential lockout, keeping recovery reachable during lockout, and the MFA recommendation. Checked 2026-08-15.
- OWASP Session Management Cheat Sheet — the web storage prohibition and its reasoning, what each cookie attribute defends against and the XSS/CSRF combination, 64-bit entropy, idle and absolute timeouts, and session regeneration after a privilege change. Checked 2026-08-15.
- The placement principle for authorization decision points (gateway, service, data layer) and the axes for the build-versus-delegate decision are not items lifted from the documents above; they are organised in this article from those requirements.
Further reading
- Related post on this blog: OAuth 2.0 Mastery — Everything About Authentication and Authorization
- Related post on this blog: JWT or Sessions, and When — Settling the Authentication Choice by Asking Where the State Lives
- Related tool: JWT decoder — a tool for reading a token's contents. It does not verify signatures, so it cannot be used to judge authenticity
- Related tools: Auth flow visualizer, Auth security lab, HTTP status codes
Complete guide series