🌐 Раздел 6 · Вопрос #10

В чём разница между 401 и 403

Разница между статусами 401 Unauthorized и 403 Forbidden заключается в фундаментальном разделении двух понятий безопасности: Аутентификации (Authentication) и Авторизации (Autho...


🟢 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), или если JWT токен выдан стороннему приложению без необходимого OAuth scope.

  4. Зачем в архитектуре Multi-tenant SaaS намеренно заменять 403 Forbidden на 404 Not Found? Ответ: Для предотвращения утечки информации (Information Disclosure) и атак класса IDOR. Код 403 подтверждает злоумышленнику, что закрытый ресурс существует. Код 404 скрывает само присутствие ресурса в системе.

Красные флаги (чего категорически нельзя говорить)

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

Связанные темы