🌐 Розділ 6 · Питання #10

В чому різниця між 401 та 403

Різниця між статусами 401 Unauthorized та 403 Forbidden полягає у фундаментальному розмежуванні двох понять безпеки: Аутентифікації (Authentication) та Авторизації (Authorization).


🟢 Junior Level

Різниця між статусами 401 Unauthorized та 403 Forbidden полягає у фундаментальному розмежуванні двох понять безпеки: Аутентифікації (Authentication) та Авторизації (Authorization).

Просте формулювання

  • 401 Unauthorized (Проблема аутентифікації) — «Хто ви? Я вас не знаю»: Сервер не зміг встановити особу клієнта. Запит надіслано без облікових даних або з недійсними/простроченими даними (токен, логін/пароль).
  • 403 Forbidden (Проблема авторизації) — «Я знаю, хто ви, але вам сюди не можна»: Особу клієнта успішно встановлено (користувач увійшов у систему), проте у нього немає прав (повноважень, ролей) для доступу до даного ресурсу.

Порівняльна аналогія з життя

  • Випадок 401: Ви намагаєтеся зайти в бізнес-центр, але забули перепустку вдома. Охоронець на вході зупиняє вас: «Покажіть документ, що посвідчує вашу особу».
  • Випадок 403: Ви показали охоронцю перепустку працівника бухгалтерії, але намагаєтеся відчинити двері серверної кімнати. Охоронець вас упізнав, але каже: «У вас немає допуску до серверної».

Поведінка клієнта

  • У разі отримання 401: Клієнтський застосунок намагається оновити сесію (наприклад, через Refresh Token) або перенаправляє користувача на форму входу.
  • У разі отримання 403: Повторний логін з тими самими обліковими даними марний. Застосунок показує повідомлення: «Доступ обмежено адміністратором».

🟡 Middle Level

Термінологічна плутанина у стандарті HTTP

У специфікації HTTP присутній історичний лінгвістичний дефект:

  • Код названо 401 Unauthorized, хоча фактично він означає Unauthenticated (відсутність підтвердженої автентичності особи).
  • Справжня авторизація (перевірка прав доступу) виражається кодом 403 Forbidden.

Протокольні вимоги (RFC 9110)

Критерій 401 Unauthorized (RFC 9110 §15.5.2) 403 Forbidden (RFC 9110 §15.5.4)
Причина Відсутні або невірні облікові дані Недостатньо прав, повноважень або обмеження IP/CORS
Обов’язковий заголовок Зобов’язаний містити заголовок WWW-Authenticate Заголовок WWW-Authenticate не передається
Повтор запиту Має сенс після надання валідних облікових даних Повтор запиту з тими самими даними заборонено
OAuth 2.0 / JWT Токен відсутній, пошкоджений або прострочений (Expired) Токен валідний, але не містить потрібного scope чи ролі

Приклад заголовка WWW-Authenticate у разі 401:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The access token expired"
Content-Type: application/problem+json

Архітектура Spring Security 6.x

В екосистемі Spring Security поділ на 401 та 403 закладено на рівні базових інтерфейсів ланцюжка фільтрів (SecurityFilterChain):

                        HTTP Request
                             │
                ┌────────────┴────────────┐
                ▼                         ▼
       Неаутентифікований?         Аутентифікований?
    (AuthenticationException)    (AccessDeniedException)
                │                         │
                ▼                         ▼
     AuthenticationEntryPoint       AccessDeniedHandler
                │                         │
                ▼                         ▼
      HTTP 401 Unauthorized      HTTP 403 Forbidden
  • AuthenticationEntryPoint: Перехоплює винятки AuthenticationException (анонімний користувач намагається отримати захищений ресурс).
  • AccessDeniedHandler: Перехоплює AccessDeniedException (користувач увійшов, але метод анотований @PreAuthorize("hasRole('ADMIN')"), а в користувача лише роль ROLE_USER).
  • CSRF Token: Помилка перевірки CSRF-токена в Spring Security за замовчуванням генерує 403 Forbidden, а не 401!

🔴 Senior Level

Безпека vs Витік метаданих (IDOR та Information Disclosure)

На рівні архітектури Senior інженер зобов’язаний враховувати вектор атак User / Resource Enumeration: Якщо зловмисник перебирає ідентифікатори закритих документів:

  • Запит GET /api/v1/documents/999 повертає 404 Not Found.
  • Запит GET /api/v1/documents/1000 повертає 403 Forbidden.

Відповідь 403 Forbidden миттєво повідомляє зловмиснику: документ з ID 1000 реально існує в системі, і до нього можна підбирати вектори атаки (IDOR).

Senior-патерн (Security by Obscurity / Silent Not Found): У мультитенентних системах (Multi-tenant SaaS) та приватних API, якщо в поточного користувача немає прав на ресурс іншої організації, система повертає 404 Not Found, а не 403 Forbidden. Це гарантує, що чужий ресурс виглядає неіснуючим.

Production-ready налаштування Spring Security 6 + RFC 9457 ProblemDetail

package com.example.security;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;

import java.io.IOException;
import java.net.URI;
import java.time.Instant;

@Configuration
@EnableMethodSecurity
public class SecurityConfiguration {

    private final ObjectMapper objectMapper;

    public SecurityConfiguration(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/v1/public/*").permitAll()
                .requestMatchers("/api/v1/admin/*").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .exceptionHandling(ex -> ex
                // Точка входу для неаутентифікованих запитів (401)
                .authenticationEntryPoint(customAuthenticationEntryPoint())
                // Обробник для відмови в доступі (403)
                .accessDeniedHandler(customAccessDeniedHandler())
            );

        return http.build();
    }

    @Bean
    public AuthenticationEntryPoint customAuthenticationEntryPoint() {
        return (request, response, authException) -> {
            ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                    HttpStatus.UNAUTHORIZED, 
                    "Full authentication is required to access this resource"
            );
            problem.setTitle("Unauthorized");
            problem.setType(URI.create("https://api.example.com/errors/unauthorized"));
            problem.setProperty("timestamp", Instant.now());

            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
            response.setHeader("WWW-Authenticate", "Bearer realm=\"api\"");
            objectMapper.writeValue(response.getOutputStream(), problem);
        };
    }

    @Bean
    public AccessDeniedHandler customAccessDeniedHandler() {
        return (request, response, accessDeniedException) -> {
            ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                    HttpStatus.FORBIDDEN, 
                    "You do not have the required permissions to perform this operation"
            );
            problem.setTitle("Forbidden");
            problem.setType(URI.create("https://api.example.com/errors/forbidden"));
            problem.setProperty("timestamp", Instant.now());

            response.setStatus(HttpStatus.FORBIDDEN.value());
            response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
            objectMapper.writeValue(response.getOutputStream(), problem);
        };
    }
}

Відмінність OAuth 2.0 Scopes vs Application Roles під час 403 помилок

У розподілених мікросервісах із токенами JWT важливо розрізняти причину отримання 403:

  1. Недостатньо Role: Користувач аутентифікований як оператор (ROLE_OPERATOR), але ендпоінт вимагає ROLE_SUPERVISOR.
  2. Недостатньо OAuth Scope: Сторонній застосунок аутентифікувався токеном від імені користувача, проте користувач під час надання згоди (Consent Screen) дозволив доступ лише на scope: "read". Спроба надсилання POST викличе відмову через insufficient_scope зі статусом 403.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь

  • 401 Unauthorized сигналізує про збій аутентифікації: сервер не знає клієнта. Токен відсутній, некоректний або прострочений. За специфікацією RFC 9110 відповідь зобов’язана містити заголовок WWW-Authenticate.
  • 403 Forbidden сигналізує про збій авторизації: сервер успішно встановив особу клієнта, проте у нього недостатньо прав, ролей або скоупів для виконання дії. Повторний виклик з тими самими обліковими даними марний.
  • У Spring Security за 401 відповідає AuthenticationEntryPoint, а за 403 — AccessDeniedHandler.
  • З метою безпеки (захист від перебору IDOR) замість 403 часто повертають 404 Not Found, щоб приховати сам факт існування закритого ресурсу.

4 каверзних питання з відповідями

  1. Який код відповіді має повернути сервер, якщо термін дії JWT токена минув (Expired)? Відповідь: Суворо 401 Unauthorized (із заголовком WWW-Authenticate: Bearer error="invalid_token", error_description="The token expired"). Відповідь 403 була б архітектурною помилкою, оскільки мобільний або вебклієнт використовує саме 401 для прозорого запуску Refresh Token Flow.

  2. Чому за специфікацією RFC 9110 код називається “Unauthorized”, якщо він стосується саме аутентифікації? Відповідь: Це історична термінологічна неточність, якої припустилися на етапі створення протоколу HTTP в 1990-х роках. Семантично 401 означає Unauthenticated, а справжня перевірка авторизації (прав доступу) покладена на 403 Forbidden.

  3. У якому випадку за наявності валідної аутентифікації адміністратора запит завершиться кодом 403 Forbidden у Spring Security? Відповідь: Наприклад, за відсутності або невалідності CSRF-токена (у stateful додатках), у разі обмеження доступу за IP (IP filtering/WAF), або якщо JWT токен видано сторонньому застосунку без обов’язкового OAuth scope.

  4. Навіщо в архітектурі Multi-tenant SaaS навмисно замінювати 403 Forbidden на 404 Not Found? Відповідь: Для запобігання витоку інформації (Information Disclosure) та атак класу IDOR. Код 403 підтверджує зловмиснику факт існування закритого ресурсу в системі. Код 404 повністю приховує присутність ресурсу.

Червоні прапорці (чого категорично не можна говорити)

  • ❌ «401 і 403 — це одне й те саме, різниці немає» (один про аутентифікацію, другий про права доступу).
  • ❌ «Якщо JWT токен прострочений, потрібно повертати 403 Forbidden» (ломає логіку автоматичного оновлення токенів у клієнтських застосунках).
  • ❌ «Якщо у користувача 403 помилка, йому просто потрібно ще раз ввести логін і пароль» (повторний логін не надає нових прав доступу).
  • ❌ «Відповідь 401 не потребує жодних обов’язкових заголовків» (за RFC 9110 заголовок WWW-Authenticate суворо обов’язковий).

Пов’язані теми