← Posts

Authentication · Authorization · Security

인증과 인가는 다른 질문이다

로그인은 됐는데 403이 뜬다. 누구인가와 무엇을 할 수 있나는 서로 다른 질문이고, 실패했을 때 답도 다르다.

목차
  1. 로그인은 됐는데 403이 뜬다
  2. 서버는 두 번 묻는다
  3. 인가는 인증 뒤에만 설 수 있다
  4. 401은 “네가 누군지 모르겠다”
  5. 403은 “누군지는 알겠는데 안 된다”
  6. 코드를 잘못 고르면 화면이 무한루프에 빠진다
  7. 인증의 답은 어딘가에 담겨 있어야 한다
  8. 실무에서 이 구분이 드러나는 자리
  9. 정리

로그인에 성공한 사용자가 관리자 화면을 열었더니 화면이 안 뜬다. 콘솔을 보니 응답이 403이다.

로그인은 됐는데 403이 뜬다

상황을 그대로 옮겨보자. 사용자는 아이디와 비밀번호를 넣었고, 서버는 200을 줬다. 로그인이 됐다는 뜻이다. 그런데 그 다음 요청에서 이런 응답이 온다.

http
GET /admin/users HTTP/1.1
Authorization: Bearer eyJhbGciOi...

HTTP/1.1 403 Forbidden

여기서 초중급자가 가장 자주 하는 반응이 “로그인이 풀렸나?”다. 그래서 다시 로그인한다. 그래도 403이다. 열 번을 다시 로그인해도 403이다.

당연하다. 로그인은 문제가 아니었기 때문이다. 서버는 이 사람이 누군지 이미 알고 있다. 알고 있는 상태에서 “당신은 이 화면에 들어올 수 없다”고 답한 것이다.

여기서 서버는 두 가지 질문을 서로 다른 시점에 물었다. 그 두 질문을 갈라놓는 것이 이 시리즈 전체의 출발점이다.

서버는 두 번 묻는다

요청 하나가 서버에 도착하면, 서버는 순서대로 두 가지를 묻는다.

인증(Authentication)은 “너 누구냐”, 인가(Authorization)는 “너 그거 해도 되냐”.

말로만 보면 비슷해 보이지만, 확인하는 재료가 완전히 다르다.

diagramdiagram
  • 인증자격 증명을 본다. 비밀번호, 세션 쿠키, 토큰 같은 것. 결과는 “이 요청의 주인은 김철수다” 또는 “누군지 모르겠다”다.
  • 인가권한을 본다. 김철수의 역할이 무엇인지, 이 리소스에 접근이 허용됐는지. 결과는 “된다” 또는 “안 된다”다.

앞의 403은 첫 관문을 통과하고 두 번째 관문에서 막힌 것이다. 그러니 다시 로그인해도 달라지지 않는다. 로그인은 첫 관문의 일이고, 막힌 곳은 두 번째 관문이다.

영어 약어로 인증은 AuthN, 인가는 AuthZ라고 쓴다. 둘 다 auth로 시작해서 대화에서 자꾸 뭉개지니, 코드나 문서에서 구분이 필요할 때 이 표기를 쓴다.

인가는 인증 뒤에만 설 수 있다

이 순서는 뒤집을 수가 없다. 누구인지 모르는데 권한을 따질 수는 없기 때문이다.

“관리자만 들어올 수 있다”는 규칙을 검사하려면 먼저 이 요청의 주인이 누구인지 알아야 하고, 그 사람의 역할이 관리자인지 봐야 한다. 주인을 모르면 볼 역할도 없다.

그래서 실제 프레임워크도 이 순서로 줄을 세운다. Spring Security의 필터 체인이 정확히 그 모양인데, 인증 필터가 앞에 서고 인가 필터가 뒤에 선다. 그 구조는 Middleware 글의 Spring Security 대목에 이미 나와 있으니 여기서 다시 풀지 않는다. 지금 기억할 것은 하나다. 순서가 취향이 아니라 논리다.

거꾸로, 예외가 있는 것처럼 보이는 경우도 있다. 공개 게시판 조회처럼 누구든 볼 수 있는 리소스는 인증 없이 통과한다. 이건 인가를 건너뛴 게 아니라, “익명 사용자에게도 허용”이라는 인가 규칙을 통과한 것이다. 주체가 “익명”일 뿐 두 관문은 그대로 있다.

401은 “네가 누군지 모르겠다”

두 관문이 갈라지니 실패 응답도 갈라진다. HTTP는 이 둘에 서로 다른 상태 코드를 준비해 뒀다.

401 Unauthorized는 인증이 실패했다는 뜻이다.

http
GET /orders HTTP/1.1

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer

토큰을 아예 안 보냈거나, 보냈는데 만료됐거나, 위조된 경우다. 서버는 이 요청의 주인이 누군지 확정하지 못했다.

401을 받았을 때 클라이언트가 할 일은 분명하다. 자격 증명을 갖춰서 다시 오는 것. 로그인 화면으로 보내거나, 토큰을 갱신해서 재시도한다. 이건 해결 가능한 실패다.

참고

이름이 헷갈린다. 규격상 401의 이름은 Unauthorized인데 실제 의미는 “인증되지 않음(Unauthenticated)“이다. HTTP 초기에 붙은 이름이 그대로 굳은 것이고, 지금은 고칠 수 없다. 이름 말고 동작으로 기억하는 편이 안전하다. 401 응답에 WWW-Authenticate 헤더를 같이 보내라고 규격이 요구하는 것도, 이 코드가 “어떻게 인증하면 되는지 알려주는” 자리이기 때문이다.

403은 “누군지는 알겠는데 안 된다”

403 Forbidden은 인가가 실패했다는 뜻이다.

http
GET /admin/users HTTP/1.1
Authorization: Bearer eyJhbGciOi...

HTTP/1.1 403 Forbidden

서버는 이 요청의 주인이 김철수라는 걸 안다. 알고 나서 거절했다. 김철수는 일반 회원이고, 이 경로는 관리자 전용이기 때문이다.

403을 받았을 때 클라이언트가 할 수 있는 일은 없다. 다시 로그인해도 여전히 김철수고, 김철수는 여전히 일반 회원이다. 자격 증명을 새로 갖춰도 결과가 안 바뀐다. 권한 자체가 바뀌어야 풀리는 실패다.

401403
실패한 관문인증인가
서버가 아는 것주인을 모른다주인을 안다
흔한 원인토큰 없음 · 만료 · 위조역할 부족 · 남의 리소스
클라이언트가 할 일로그인 · 토큰 갱신 후 재시도재시도해도 소용없다
화면로그인 페이지로”권한이 없습니다”

코드를 잘못 고르면 화면이 무한루프에 빠진다

이걸 왜 이렇게 따지나. 상태 코드가 곧 클라이언트의 행동을 정하기 때문이다.

대부분의 프런트엔드는 응답 인터셉터에 이런 규칙을 둔다.

java
// 서버 쪽 예외 처리 - 이 코드가 클라이언트의 다음 행동을 정한다
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorBody> denied() {
    return ResponseEntity.status(403).body(new ErrorBody("권한이 없습니다"));
}

여기서 서버가 실수로 403을 줘야 할 자리에 401을 주면 어떻게 되나.

diagramdiagram

클라이언트는 “인증이 문제”라고 믿고 토큰을 갱신해서 재시도한다. 권한은 그대로니 또 401이 온다. 로그인 화면과 원래 화면을 오가는 루프에 빠지거나, 로그인이 방금 됐는데도 자꾸 로그인 화면으로 튕기는 증상이 된다.

반대로 401을 줘야 할 자리에 403을 주면, 만료된 토큰을 갱신하면 풀릴 상황인데 화면은 “권한이 없습니다”를 띄운다. 사용자는 자기가 권한이 없다고 믿고 문의를 넣는다.

주의

보안상 일부러 403 대신 404를 주는 선택도 있다. 남의 리소스에 403을 주면 “그 번호의 리소스가 존재한다”는 사실이 새기 때문이다. 이건 의도된 절충이고, 의도 없이 코드가 섞이는 것과는 다르다. 무엇을 숨기려는 건지 말할 수 없다면 그냥 규격대로 쓰는 편이 낫다.

인증의 답은 어딘가에 담겨 있어야 한다

여기서 이 시리즈의 나머지가 출발한다.

인증은 로그인할 때 한 번 일어난다. 그런데 그 뒤에 오는 요청은 수십, 수백 개다. 요청마다 비밀번호를 다시 받을 수는 없다.

diagramdiagram

그래서 로그인 결과를 어딘가에 담아두고, 이후 요청은 그걸 내민다. HTTP는 요청 하나하나를 독립적으로 처리해서 방금 누가 왔는지 스스로 기억하지 않기 때문에, 이 “담아두는 자리”를 우리가 직접 만들어야 한다.

그 자리를 서버에 두느냐 클라이언트에 두느냐가 갈림길이다.

  • 서버가 기억한다 - 서버가 로그인 정보를 저장하고 브라우저에는 번호표만 준다. 쿠키와 세션이 이 방식이다.
  • 서버가 기억하지 않는다 - 필요한 정보를 담고 위조 못 하게 봉인한 문자열을 준다. JWT가 이 방식이다.

인가도 마찬가지로 어딘가에 답이 있어야 한다. “김철수의 역할이 관리자인가”를 매번 어떻게 알아내는지, 그걸 역할로 표현할지 다른 방식으로 표현할지가 남는다.

실무에서 이 구분이 드러나는 자리

Spring Security는 이 두 질문을 아예 다른 타입으로 갈라놨다.

java
// 인증 결과 - "누구인가"
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
auth.getName();           // 주체 식별자
auth.getAuthorities();    // 이 주체가 가진 권한들

// 인가 규칙 - "무엇을 할 수 있나"
http.authorizeHttpRequests(reg -> reg
        .requestMatchers("/admin/**").hasRole("ADMIN")
        .requestMatchers("/orders/**").authenticated()
        .anyRequest().permitAll());

읽어보면 두 관문이 그대로 보인다. authenticated()는 “누구든 신원만 확인되면 통과”라서 첫 관문만 요구하고, hasRole("ADMIN")은 둘 다 요구한다. permitAll()은 익명도 통과다.

실패 처리 지점도 둘로 나뉜다.

인터페이스언제 불리나기본 응답
AuthenticationEntryPoint인증이 안 된 채로 보호된 곳에 왔을 때401
AccessDeniedHandler인증은 됐는데 권한이 모자랄 때403

이름이 다르고, 불리는 시점이 다르고, 내보내는 코드가 다르다. 프레임워크가 이렇게까지 갈라놓은 이유는 두 실패에 대해 애플리케이션이 해야 할 일이 실제로 다르기 때문이다. 401은 로그인 화면으로 유도하고, 403은 감사 로그에 남겨 “누가 무엇을 시도했는지” 추적하는 식이다.

참고

Spring Security에서 hasRole("ADMIN")이라고 쓰면 실제로는 ROLE_ADMIN이라는 권한 문자열을 찾는다. ROLE_ 접두사를 자동으로 붙이기 때문인데, 권한을 저장할 때 접두사를 빠뜨리면 조용히 403이 난다. 흔히 밟는 자리다.

정리

인증”너 누구냐”. 자격 증명을 보고 주체를 확정한다
인가”너 그거 해도 되냐”. 권한을 보고 허용을 정한다
순서인증이 먼저. 누군지 모르면 권한을 따질 수 없다
401인증 실패. 자격 증명을 갖춰 다시 오면 풀린다
403인가 실패. 다시 로그인해도 안 풀린다
코드를 섞으면클라이언트가 엉뚱한 복구 동작을 한다
남는 질문인증 결과를 어디에 담아둘 것인가

로그인 한 번의 결과를 이후 요청이 계속 내밀 수 있어야 한다. 그 답을 서버가 기억하는 쪽부터 본다. 다음 글에서 쿠키와 세션을 연다.

관련 글