인증과 인가: 401과 403, 재판정 시점, OAuth 2.0
들어가며
로그인은 멀쩡히 되는데 특정 API 만 403 을 돌려주는 경우가 있습니다. 반대로 팀에서 빠진 사람이 예전에 받은 승인으로 문서를 계속 내려받는 문제도 생깁니다. 둘 다 "누구인가"와 "해도 되는가"를 어떻게 나누고, 언제 확인하느냐의 문제입니다.
이번 글에서는 인증과 인가의 차이, 401 과 403 의 구분, 인가를 판정해야 하는 시점을 정리합니다. 로그인 프로토콜로 자주 오해받는 OAuth 2.0 의 역할도 함께 다룹니다.
1. 인증과 인가
인증(Authentication) 은 요청을 보낸 주체가 누구인지 확인하는 일이고, 인가(Authorization) 는 그 주체가 이 자원에 이 동작을 해도 되는지 판정하는 일입니다. 인가는 주체를 알아야 판정할 수 있으므로 순서는 인증이 먼저입니다.
HTTP 응답에서도 둘은 다르게 표현됩니다. 인증 정보가 없거나 유효하지 않으면 401 Unauthorized, 누구인지는 알지만 권한이 없으면 403 Forbidden 입니다. 401 의 이름에 Unauthorized 가 들어 있어 헷갈리지만, 실제 의미는 인증 실패입니다.
인증은 호텔 프런트의 신분 확인, 인가는 카드키가 열 수 있는 방의 범위예요.
| 요청 | 인증 | 인가 | 응답 |
|---|---|---|---|
| 토큰 없음GET | 401 | ||
| 보기로 조회GET | 200 | ||
| 보기로 생성POST | 403 | ||
| 쓰기로 생성POST | 201 |
GET /reports/7 → 401 # 토큰 없음
GET /reports/7 (보기 권한) → 200
POST /reports (보기 권한) → 403
POST /reports (쓰기 권한) → 201클라이언트 입장에서 둘의 대응도 다릅니다. 401 은 다시 로그인하거나 토큰을 갱신하면 풀릴 수 있지만, 403 은 다시 로그인해도 권한이 없으면 그대로입니다.
2. 인가는 언제 판정하는가
권한은 고정된 값이 아닙니다. 요청을 승인한 뒤 실제로 자원을 내주기 전에 담당자가 팀에서 빠질 수도 있습니다. 이렇게 검사 시점과 사용 시점 사이의 틈에서 생기는 문제를 TOCTOU(Time Of Check to Time Of Use)라고 합니다.
인가는 호텔 문을 열 때마다 카드키를 확인하는 일이에요. 받을 때 한 번 확인하고 끝내지 않아요.
이미 권한이 없는 사용자에게 문서가 나간다.
같은 상황에서 내주기 직전 판정이 요청을 거절한다.
그래서 민감한 자원은 값을 내주기 직전에 원본 권한으로 다시 판정합니다.
# 나쁨: 승인 때의 판정을 그대로 사용
request = approve(user, document) # 이때는 통과
# ...그사이 권한이 회수됨...
return document.content
# 좋음: 내주기 직전에 다시 판정
if not can_read(user, document):
raise NotFound() # 존재 여부도 숨김
return document.content위 코드가 403 대신 404 를 돌려주는 데에는 이유가 있습니다. 응답 차이로 어떤 자원이 존재하는지 훑는 열거 공격(enumeration) 을 막기 위해서입니다. 다만 항상 그래야 하는 것은 아닙니다. 존재를 알아도 되는 자원이라면 403 이 클라이언트에게 더 정확한 정보를 줍니다.
판정 위치는 서버입니다. 화면에서 버튼을 숨기는 것은 사용 편의를 위한 처리일 뿐이고, API 를 직접 호출하면 그대로 통과합니다. 요청마다 서버에서 확인하고 기본값은 거부로 둡니다.
3. OAuth 2.0: 권한 위임
OAuth 2.0 은 사용자의 비밀번호를 다른 앱에 넘기지 않고, 범위(scope)가 제한된 액세스 토큰으로 권한을 위임하는 표준입니다. 일정 관리 앱에 캘린더 읽기 권한만 주는 상황이 대표적입니다.
서버 쪽 앱에서 많이 쓰는 인가 코드 흐름은 아래와 같습니다. 앱은 비밀번호를 보지 못하고, 사용자가 동의한 범위의 토큰만 받습니다.
투숙객이 신분증을 넘기지 않고, 룸서비스 직원에게 정해진 범위만 여는 임시 카드키를 발급해 주는 방식이에요.
GET /authorize?response_type=code
&client_id=calendar-app
&scope=calendar.read
&redirect_uri=https://app.example.com/callback
&state=xyz
&code_challenge=abc
&code_challenge_method=S256인가 코드를 가로채는 공격을 막기 위해 PKCE 를 함께 씁니다. 최근 보안 권고인 RFC 9700 도 인가 코드 흐름에 PKCE 사용을 권장합니다.
로그인은 OAuth 자체의 기능이 아닙니다. 사용자가 누구인지 알려 주는 일은 그 위에 얹은 OpenID Connect(OIDC) 의 ID 토큰이 맡습니다.
헷갈리기 쉬운 점
- OAuth 를 로그인 프로토콜로 보는 오해. OAuth 는 권한 위임용입니다. "구글로 로그인" 같은 기능은 보통 OIDC 를 함께 사용합니다.
- 로그인 때 한 번 확인하면 된다는 오해. 권한은 중간에 바뀌므로 인가는 요청마다 서버에서 판정합니다.
- 토큰이 유효하면 권한도 유효하다는 오해. 토큰의 서명과 만료를 확인하는 것은 인증에 가깝습니다. 이 사용자가 이 자원을 다룰 수 있는지는 따로 판정합니다.
면접에서 자주 받는 질문
OAuth 액세스 토큰과 OIDC ID 토큰은 어떻게 다른가요?
액세스 토큰은 API 를 호출할 권한의 증표라 리소스 서버가 확인합니다. ID 토큰은 누가 로그인했는지 담은 JWT 로, 클라이언트 앱이 읽습니다. ID 토큰으로 API 를 호출해서는 안 됩니다.
JWT 안에 역할(role)을 넣으면 생기는 문제는요?
토큰이 만료되기 전까지는 역할을 바꿔도 반영되지 않습니다. 그래서 수명을 짧게 두거나, 민감한 판정은 서버의 최신 권한으로 합니다.
매번 재판정하면 느리지 않나요?
권한을 캐시하되 TTL 을 짧게 두고, 권한이 바뀌면 캐시를 무효화합니다. 민감한 동작만 매번 원본에서 판정하는 식으로 나누기도 합니다.
정리
- 인증은 누구인지 확인하고, 인가는 해도 되는지 판정합니다. 실패 응답은 각각 401 과 403 입니다.
- 권한은 바뀔 수 있으므로 민감한 자원은 내주기 직전에 서버에서 다시 판정합니다.
- OAuth 2.0 은 권한 위임 표준이고, 로그인은 OIDC 가 맡습니다.
인증이 신분증이라면 인가는 카드키이고, 그 카드키는 받을 때가 아니라 문을 열 때 확인합니다.