1. 라이브러리
  2. HTTP와 웹
  3. HTTP 상태 코드

업데이트됨 1개월 전

HTTP 명세는 아쉽게도 이름을 잘못 붙였습니다. 401은 "Unauthorized(미승인)"라고 불리지만 실제로는 미인증을 의미합니다—서버가 당신이 누구인지 모른다는 뜻입니다. 진짜 인가 실패는 403 "Forbidden(금지)"입니다—서버는 당신이 누구인지 정확히 알고 있으며, 그 행동을 할 수 없다고 결정한 것입니다.

이 명명 혼란은 거의 다른 어떤 HTTP 오해보다 더 많은 API 버그를 일으킵니다. 이 구분을 한 번 명확히 이해하고 나면, 두 번 다시 혼동하지 않을 것입니다.

건물 보안 비유

이것은 단순한 비유가 아닙니다—인증과 인가가 실제로 작동하는 방식입니다:

401 Unauthorized: 당신은 ID 배지 없이 문 앞에 서 있습니다. 보안 요원은 당신이 누구인지 모릅니다. 당신의 신원을 모르기 때문에 보안 등급을 확인할 수 없습니다. 먼저 배지를 보여주세요.

403 Forbidden: 당신은 ID 배지가 있습니다. 보안 요원은 당신이 엔지니어링 부서의 Alice라는 것을 압니다. 하지만 당신은 임원 층에 들어가려고 하는데, 엔지니어링 부서의 Alice는 출입 목록에 없습니다. 배지를 아무리 다시 보여줘도 달라지지 않습니다.

보안 요원은 항상 권한을 확인하기 전에 신원을 먼저 확인합니다. 인증이 인가에 선행합니다. 언제나.

401: "당신은 누구입니까?"

401 응답은 다음을 의미합니다:

  • 인증 정보가 제공되지 않았거나
  • 인증 정보가 유효하지 않거나 (잘못된 비밀번호, 형식이 잘못된 토큰), 또는
  • 인증 정보가 만료되었습니다

해결 방법은 언제나 같습니다: 유효한 인증 정보를 제공하고 다시 시도하세요.

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api"

{
    "error": "Authentication required"
}

WWW-Authenticate 헤더는 HTTP 명세에서 필수입니다. 클라이언트에게 사용할 인증 방식을 알려줍니다—Bearer 토큰, Basic 인증, 혹은 다른 방식. 이 헤더를 빠뜨리면 명세 위반입니다.

401을 반환해야 할 때

인증 정보가 제공되지 않은 경우:

GET /api/profile HTTP/1.1
// Authorization 헤더 없음

→ 401 Unauthorized

유효하지 않은 인증 정보:

POST /api/login HTTP/1.1
{"username": "alice", "password": "wrong"}

→ 401 Unauthorized

만료된 토큰:

GET /api/profile HTTP/1.1
Authorization: Bearer expired-token

→ 401 Unauthorized

클라이언트가 대응하는 방법

401을 받으면: 재인증하세요. 토큰을 갱신하세요. 사용자에게 다시 로그인하도록 안내하세요. 그런 다음 요청을 재시도하세요.

async function makeRequest(url) {
    const response = await fetch(url, {
        headers: { 'Authorization': `Bearer ${getToken()}` }
    });

    if (response.status === 401) {
        const newToken = await refreshToken();
        if (newToken) {
            return fetch(url, {
                headers: { 'Authorization': `Bearer ${newToken}` }
            });
        }
        redirectToLogin();
    }

    return response;
}

403: "대답은 '아니오'입니다"

403 응답은 다음을 의미합니다:

  • 서버는 당신이 누구인지 알고 있습니다 (인증 성공)
  • 이 특정 작업을 수행할 권한이 없습니다
  • 재인증해도 소용없습니다—이것은 정책적 결정입니다
HTTP/1.1 403 Forbidden

{
    "error": "You don't have permission to access this resource"
}

WWW-Authenticate 헤더는 필요 없습니다. 문제는 인증이 아닙니다.

403을 반환해야 할 때

다른 사용자의 데이터에 접근하는 경우:

DELETE /api/users/456 HTTP/1.1
Authorization: Bearer valid-token-for-user-123

→ 403 Forbidden (자신의 계정만 삭제할 수 있습니다)

필요한 역할이 없는 경우:

POST /api/admin/users HTTP/1.1
Authorization: Bearer valid-token-for-regular-user

→ 403 Forbidden (관리자 역할 필요)

계정이 정지된 경우:

GET /api/data HTTP/1.1
Authorization: Bearer valid-token-for-suspended-user

→ 403 Forbidden (계정 정지됨)

클라이언트가 대응하는 방법

403을 받으면: 재시도하지 마세요. 문제는 인증 정보가 아니라 권한입니다. 사용자에게 오류 메시지를 보여주세요. 사용자가 실제로 접근할 수 있는 곳으로 안내하세요.

if (response.status === 403) {
    showError("You don't have permission to do this");
    redirectToHome();
    // 재시도하지 마세요. 도움이 되지 않습니다.
}

결정 과정

요청이 들어오면:

  1. 인증 정보가 있습니까? 없음 → 401
  2. 인증 정보가 유효합니까? 아니오 → 401
  3. 이 사용자에게 권한이 있습니까? 아니오 → 403
  4. 그 외의 경우 → 요청 처리

인증은 인가보다 먼저 이루어집니다. 신원을 확인하지 않은 사람의 권한을 확인할 수는 없습니다.

전체 예시

관리자 전용 엔드포인트:

// 인증 정보 없음
POST /api/admin/users HTTP/1.1
→ 401 Unauthorized

// 유효한 인증 정보, 일반 사용자
POST /api/admin/users HTTP/1.1
Authorization: Bearer token-for-alice
→ 403 Forbidden

// 유효한 인증 정보, 관리자 사용자
POST /api/admin/users HTTP/1.1
Authorization: Bearer token-for-admin
→ 201 Created

서버 구현

function requireAuth(request, response, next) {
    const token = request.headers.authorization?.split(' ')[1];

    if (!token) {
        return response.status(401)
            .set('WWW-Authenticate', 'Bearer realm="api"')
            .json({ error: 'Authentication required' });
    }

    try {
        request.user = verifyToken(token);
        next();
    } catch (error) {
        return response.status(401)
            .set('WWW-Authenticate', 'Bearer realm="api"')
            .json({ error: 'Invalid token' });
    }
}

function requireAdmin(request, response, next) {
    // 이 미들웨어는 requireAuth가 이미 실행되었다고 가정합니다
    if (!request.user.isAdmin) {
        return response.status(403)
            .json({ error: 'Admin role required' });
    }
    next();
}

// 사용법: 인증 먼저, 그 다음 인가
app.post('/api/admin/users', requireAuth, requireAdmin, handleCreateUser);

흔한 실수들

403을 써야 할 때 401을 사용하는 경우:

DELETE /api/users/456
Authorization: Bearer valid-token-for-user-123

❌ 401 Unauthorized  // 틀림—이 사용자는 인증된 상태입니다
✓ 403 Forbidden

401을 써야 할 때 403을 사용하는 경우:

GET /api/profile
// Authorization 헤더 없음

❌ 403 Forbidden  // 틀림—아직 누구인지 모릅니다
✓ 401 Unauthorized

WWW-Authenticate를 빠뜨리는 경우:

❌ HTTP/1.1 401 Unauthorized
   {"error": "Not authenticated"}

✓ HTTP/1.1 401 Unauthorized
   WWW-Authenticate: Bearer realm="api"
   {"error": "Authentication required"}

보안 고려사항: 리소스 존재 여부 숨기기

때로는 리소스가 존재하는지 여부를 숨기고 싶을 수 있습니다:

GET /api/documents/secret-id HTTP/1.1
Authorization: Bearer valid-token

// 옵션 1: 솔직하게
→ 404 Not Found (리소스가 존재하지 않음을 드러냄)

// 옵션 2: 불투명하게
→ 403 Forbidden (존재 여부를 숨김)

존재하지 않는 리소스에 403을 사용하면 열거 공격을 방지할 수 있지만, API 투명성은 낮아집니다. 보안 요구사항에 따라 선택하세요.

401과 403에 관한 자주 묻는 질문

401이 미인증을 의미한다면 왜 "Unauthorized(미승인)"라고 불릴까요?

역사적인 우연입니다. 1996년 HTTP/1.0이 정의될 당시에는 용어가 지금처럼 정확하지 않았습니다. "Unauthenticated(미인증)"가 더 명확하다는 것을 사람들이 깨달았을 때, 이미 그 이름은 수많은 시스템에 깊이 자리 잡고 있었습니다. 명세는 혼란스러운 이름을 그대로 안고 있지만, 여러분은 그것에 혼란스러울 필요가 없습니다.

만료된 토큰에 대해 401을 반환해야 합니까, 아니면 403을 반환해야 합니까?

401입니다. 만료된 토큰은 유효하지 않은 인증입니다—서버는 더 이상 유효하지 않은 인증 정보로는 신원을 확인할 수 없습니다. 클라이언트는 토큰을 갱신하고 재시도해야 합니다.

API 키는 어떻습니까? 인증입니까, 아니면 인가입니까?

API 키는 인증입니다—신원을 증명합니다("이 요청은 애플리케이션 X에서 온 것입니다"). API 키가 없거나 유효하지 않으면 401을 반환하세요. API 키가 유효하지만 이 작업에 대한 권한이 없으면 403을 반환하세요.

리소스가 존재하지 않는다는 것을 숨기기 위해 403을 사용할 수 있습니까?

네, 하지만 절충점을 이해해야 합니다. 404 대신 403을 반환하면 공격자가 열거를 통해 유효한 리소스 ID를 발견하는 것을 막을 수 있습니다. 하지만 정당한 사용자에게는 디버깅이 더 어려워집니다. 대부분의 API는 존재하지 않는 리소스에 404를 사용하고, 실제 권한 거부에는 403을 씁니다.

이 페이지가 도움이 되었나요?

😔
🤨
😃