JavaRush /Курсы /Spring Security /401 и

401 и 403: семантика security-ошибок

Spring Security
17 уровень , 1 лекция
Открыта

1. Различие 401 и 403 в REST API

Когда вы начинаете делать «настоящий» JSON API, вы довольно быстро понимаете: статус-код — это не «формальность ради галочки», а часть контракта. Клиентское приложение, мобильный клиент, Postman-коллекция и даже ваш будущий автотест читают не только тело ответа. Они прежде всего читают статус и делают по нему вывод: надо логиниться, надо показать экран “нет доступа”, надо повторить запрос или, наоборот, срочно перестать спамить сервер.

Проблема начинается, когда API отвечает «как попало»: то 403 вместо 401, то наоборот. Для человека-разработчика это неприятно, но терпимо: он откроет логи, посмотрит конфиг и догадается. Для клиента это превращается в неверное поведение. Например, приложение думает, что у пользователя “нет прав”, хотя на самом деле он просто не вошёл. Или клиент начинает бесконечно “перелогиниваться”, хотя логин успешен — просто роль не та.

Чтобы зафиксировать смысл, можно держать в голове маленькую таблицу (это не «священная истина», а удобная шпаргалка для проектирования):

Ситуация на сервере Правильный смысл Типичный код
Пользователь не аутентифицирован (у нас нет подтверждённой личности для запроса) “Сначала представься” 401
Пользователь аутентифицирован, но правило доступа не выполняется (роль/authority/другая проверка не прошла) “Мы тебя узнали, но сюда нельзя” 403

Важно: эти коды не про «насколько сильно вы провинились». Это не “401 — лёгкий штраф, 403 — бан навсегда”. Это разные типы ошибок, и они отвечают на разные вопросы.

2. Ментальная модель: идентификация и доступ

Очень хочется запомнить 401 и 403 как два магических числа, но числа без смысла плохо хранятся в голове. Поэтому нормальная ментальная модель всегда начинается с человеческой истории. Представьте, что наш API — это офис с охраной. На входе у вас спрашивают две разные вещи.

Сначала охранник спрашивает “кто вы?”. Если вы не показываете пропуск, показываете чужой пропуск или показываете что-то, что не похоже на пропуск (например, студенческий билет из 2007 года — он тоже пластик, но не надо), то разговор заканчивается быстро. Это 401: у системы нет основания считать, что запрос делает подтверждённый пользователь.

А если вы пропуск показали, охранник вас узнал, но вы пытаетесь пройти в серверную или в кабинет директора, то тут уже другая история. Вопрос “кто вы?” решён. Но возникает вопрос “что вам можно?”. И ответ может быть “нет”. Это 403: личность понятна, но права недостаточны.

Вот так это можно представить в виде простой схемы обработки запроса (упрощённо, но полезно для мозга):

flowchart TD
    A[HTTP request] --> B{"Есть успешная аутентификация?"}
    B -- нет --> U[Ответ: 401]
    B -- да --> C{"Правило доступа выполнено?"}
    C -- нет --> F[Ответ: 403]
    C -- да --> D[Контроллер / бизнес-логика]

Если вы держите в голове эту развилку, вы почти перестаёте путаться. Любой спор “это 401 или 403?” сводится к одной проверке: “у нас вообще есть подтверждённый пользователь в контексте запроса или нет?”.

3. 401 Unauthorized: когда у вас “нет пользователя”

401 — это ситуация, когда вы обращаетесь к защищённому ресурсу, но для текущего запроса не удалось получить успешную аутентификацию. Важно, что “не удалось” — это не только “не прислал ничего”. Это также “прислал, но неправильно”, “прислал просроченное”, “прислал логин/пароль, но они не сошлись”, “аккаунт заблокирован и не проходит authentication flow”.

Да, название Unauthorized реально путает. В английском это исторически закрепившийся термин, но в нашем курсе мы будем мыслить проще: 401 почти всегда означает Unauthenticated. То есть “система не считает вас вошедшим”.

Чтобы увидеть это на живом проекте Secure Content Platform API, возьмём приватный endpoint /api/me. По нашей модели доступа он должен работать только для аутентифицированного пользователя. А значит, без логина он обязан «упереться» в 401.

Контроллер, условно, может выглядеть так (мы его показываем не ради логики, а ради ясности: до метода запрос дойдёт только если security пропустит):

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
class MeController {

    // Приватный endpoint: сюда должны пускать только после успешной аутентификации
    @GetMapping("/api/me")
    public Map<String, String> me() {
        // Если вы видите это сообщение, значит security-слой пропустил запрос внутрь
        return Map.of("message", "private zone");
    }
}

Теперь посмотрим на запрос без аутентификации (самый честный способ — curl -i, чтобы видеть статус):

# -i нужен, чтобы увидеть статус-код и заголовки ответа
curl -i http://localhost:8080/api/me
# HTTP/1.1 401

Если вы используете HTTP Basic (мы его уже проходили, и он удобен именно для API-клиента), то неверные credentials тоже дадут 401, потому что аутентификация не состоялась:

# Неверные учётные данные => аутентификация неуспешна => 401
curl -i -u anna:wrong http://localhost:8080/api/me
# HTTP/1.1 401

Обратите внимание на тонкий момент: если у вас в проекте есть пользователь anna, но пароль неверный, это всё равно “у нас нет успешной аутентификации”. То есть это не “403, потому что Anna же существует”. Нет. Для текущего запроса пользователь не доказал, что он Anna.

И ещё один важный нюанс из предыдущих дней: если аккаунт locked или disabled, то логин тоже не считается успешным. С точки зрения запроса — это снова 401. Внутри Spring Security там могут быть разные причины (и разные исключения), но внешнему клиенту это обычно сводится к одному: “аутентификация не прошла”.

4. 403 Forbidden: когда пользователь есть, но нельзя

403 — это уже другой тип истории. Это “мы тебя узнали, но правило доступа не выполнено”. В терминах Spring Security это означает: в SecurityContext есть Authentication, она считается успешной (то есть это не anonymous и не “провалился пароль”), но на этапе авторизации кто-то сказал “нет”.

Чаще всего в учебном проекте причина 403 очень понятная: у пользователя не та роль или не та authority. Например, обычный USER пытается сходить в admin-зону. Но полезно помнить: 403 может прилететь не только из-за роли. В session-based ветке, например, отсутствие CSRF-токена на state-changing запросе тоже часто проявляется как 403. Мы сегодня не углубляемся в CSRF, но как факт для отладки это стоит помнить, чтобы не думать “403 = точно роль”.

Для нашего проекта идеальный пример — endpoint /api/admin/users. Он должен быть доступен только роли ADMIN. Значит, обычный пользователь после успешного логина должен получить 403.

Контроллер для примера:

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
class AdminController {

    // Admin-endpoint: сюда должны попадать только пользователи с ролью ADMIN
    @GetMapping("/api/admin/users")
    public Map<String, String> users() {
        // Если вы видите этот ответ, значит проверка ролей уже пройдена
        return Map.of("message", "admin zone");
    }
}

А теперь — запрос. Допустим, anna успешно аутентифицируется, но у неё роль USER. Тогда:

# Учётные данные верные => аутентификация есть, но прав не хватает => 403
curl -i -u anna:pass http://localhost:8080/api/admin/users
# HTTP/1.1 403

Это именно 403, потому что вопрос “кто ты?” уже закрыт. Мы знаем, что запрос делает anna, просто она не админ.

Если же вообще не прислать аутентификацию, то на этот же endpoint будет 401, потому что вопрос “кто ты?” даже не начался нормально:

# Нет аутентификации => 401 (а не 403)
curl -i http://localhost:8080/api/admin/users
# HTTP/1.1 401

Это ключевой паттерн: один и тот же URL может отдавать 401 или 403 в зависимости от того, аутентифицирован ли клиент.

5. Один endpoint: три исхода

Новичку особенно сложно привыкнуть к мысли, что статус зависит не только от URL, но и от состояния security‑контекста. Поэтому очень полезно один раз увидеть «три судьбы одного endpoint’а». Возьмём /api/admin/users и посмотрим, что произойдёт в трёх состояниях: anonymous, обычный пользователь, админ.

В виде таблицы это выглядит так:

Кто делает запрос Что есть у сервера Ожидаемый результат
Anonymous (ничего не прислал) Нет успешной аутентификации 401
USER (успешно вошёл) Есть authenticated user, но нет ROLE_ADMIN 403
ADMIN (успешно вошёл) Есть authenticated user + есть нужная роль 200

А руками, через curl, это можно прожить буквально за минуту:

# 1) Anonymous => нет аутентификации => 401
curl -i http://localhost:8080/api/admin/users
# HTTP/1.1 401

# 2) USER => аутентификация есть, но не хватает роли => 403
curl -i -u anna:pass http://localhost:8080/api/admin/users
# HTTP/1.1 403

# 3) ADMIN => всё ок => 200
curl -i -u root:pass http://localhost:8080/api/admin/users
# HTTP/1.1 200

(И да, если вы сейчас подумали “а можно я всегда буду отвечать 403, чтобы не думать?” — можно, но тогда клиент будет думать вместо вас. И он будет думать неправильно.)

Этот пример ещё раз закрепляет смысл: 401 — это “не вошёл”, 403 — это “вошёл, но нельзя”.

6. Как Spring Security принимает решение: два “барьера”

Если вы воспринимаете Spring Security как “один большой охранник”, то путаетесь быстрее. Полезнее видеть систему как два последовательных барьера, которые работают в фильтрах, до входа в контроллер. Первый барьер занимается тем, чтобы получить успешную аутентификацию и положить её в SecurityContext. Второй барьер проверяет правила доступа к конкретному запросу и решает, пропускать ли дальше.

На практике это очень хорошо читается прямо из SecurityFilterChain. Посмотрите на типичную конфигурацию для наших зон:

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth
            // Правило №1: всё под /api/admin/** требует роль ADMIN (это барьер авторизации)
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            // Правило №2: любые остальные запросы требуют хотя бы аутентификацию (это барьер аутентификации)
            .anyRequest().authenticated()
        )
        // Дальше Spring Security сам решит, что вернуть: 401 (нет аутентификации) или 403 (прав не хватает)
        .build();
}

Здесь прячется вся логика кодов:

Если запрос пришёл на “что угодно”, кроме /api/admin/**, то требуется authenticated(). Если аутентификации нет — это первый барьер, и дальше смысла проверять права нет. Поэтому логичный результат — 401.

Если запрос пришёл на /api/admin/**, то требуется роль ADMIN. И вот тут появляется развилка: если аутентификации нет, опять же сначала 401 (первый барьер). Если аутентификация есть, но роли нет — это уже второй барьер, и он заканчивается 403.

Полезная мысль для мозга: правила authenticated(), hasRole("ADMIN"), hasAuthority("ADMIN") — это не “три разных способа вернуть код”. Это разные условия пропуска. А код (401 или 403) выбирается в зависимости от того, на каком этапе вы “споткнулись”: на входе (нет user’а) или на допуске (user есть, но не подходит).

7. Как ошибка выбора 401/403 ломает клиента

Кажется, что “ну подумаешь, код на 1 цифру отличается”. Но в клиентском мире эти цифры — команды. Многие клиенты строят поведение так: если 401, значит надо показать логин-экран или обновить учётные данные. Если 403, значит логин уже не поможет, нужно показать сообщение “нет доступа” и не пытаться “чинить” запрос повтором.

Если вы вернёте 403 там, где пользователь просто не вошёл, клиент может сделать очень странный UX. Представьте мобильное приложение: человек ставит приложение, открывает раздел профиля, а ему сразу: “Доступ запрещён”. Он ещё даже логин не увидел. Вероятность, что он подумает “о, надо нажать кнопку входа в другом месте”, примерно такая же, как вероятность, что кот сам настроит вам SecurityFilterChain (то есть почти ноль).

Если же вы вернёте 401 там, где пользователь вошёл, но не имеет прав, клиент может уйти в бесконечный цикл “переавторизоваться и попробовать ещё раз”. Иногда это выглядит как постоянный вылет на экран логина после каждого запроса к admin-зоне, даже если логин правильный. Человек вводит пароль снова и снова, начинает ненавидеть ваше приложение, а вы начинаете ненавидеть жизнь.

Поэтому правильная семантика 401/403 — это не эстетика. Это реальная устойчивость клиент-серверного поведения.

8. Шпаргалка для отладки: как читать 401 и 403

Когда вы видите 401, полезно не начинать с паники “всё сломалось”, а задать себе первый базовый вопрос: “а сервер вообще видит пользователя?”. И дальше мыслить очень приземлённо: был ли в запросе Authorization header (если это Basic), была ли session cookie (если это session-based ветка), не заблокирован ли аккаунт, не промахнулись ли мы с логином/паролем.

Когда вы видите 403, вопрос уже другой: “пользователь есть, но какое правило он не прошёл?”. И тут начинается чтение матрицы доступа. Если конфиг написан аккуратно, вы довольно быстро находите соответствующий matcher. Если вы используете роли, вы сравниваете ожидаемую роль (ADMIN) с реальными ролями пользователя. Если вы уже начали использовать authorities, то проверяете, что в GrantedAuthorities действительно присутствует нужная строка (и что вы не перепутали hasRole("ADMIN") с hasAuthority("ADMIN"), потому что это разные миры).

На уровне мышления можно держать такой порядок: сначала доказать, что аутентификация существует (иначе любые разговоры про роли бессмысленны), и только потом разбирать права. Это сильно экономит время, потому что большинство “403” новичка на самом деле оказывается “я не передал cookie/Authorization” и это должен был быть 401, но где-то включилась browser-semantics или клиент ожидал другое поведение.

От этой развилки напрямую зависит и сама механика ответа: отсутствие аутентификации должно уходить в одну security‑точку, а запрет для уже известного пользователя — в другую. Когда эти две ветки разделены правильно, их уже можно свести к одному JSON‑контракту.

9. Типичные ошибки при различении 401 и 403

Когда вы только начинаете, очень хочется иметь «универсальное объяснение» вроде “401 — это когда пароль неправильный, 403 — когда роль неправильная”. И это почти правда, но именно “почти” и приводит к головной боли. Ошибки тут повторяются из проекта в проект, так что лучше познакомиться с ними сейчас, пока у нас учебный проект, а не горящий прод.

Ошибка №1: воспринимать 401 как “не та роль”.
Это самая популярная путаница из‑за слова Unauthorized. На самом деле роль проверяется только после того, как пользователь успешно аутентифицирован. Если логин/пароль не прошли или вообще не были переданы — это не “роль не та”, это “пользователя нет”. Поэтому базовая проверка такая: прежде чем думать про роли, убедитесь, что запрос вообще был аутентифицирован.

Ошибка №2: отдавать 403 для anonymous-запроса к приватному endpoint’у.
Такое часто случается, когда разработчик «ручками» возвращает статусы из контроллера, или когда настройки сделаны так, что браузерный flow маскирует реальный смысл ошибки. Для /api/me anonymous должен получать 401. Если он получает 403, клиент начинает думать, что “доступ запрещён навсегда”, хотя на самом деле надо просто войти.

Ошибка №3: отдавать 401, когда пользователь уже вошёл, но не имеет прав.
Например, USER идёт на /api/admin/users, получает 401, клиент решает “токен/сессия протухли” и гонит пользователя перелогиниваться. Пользователь честно логинится снова, и снова получает 401. Это очень плохо выглядит и превращает систему в бесконечный коридор с дверью, которая всегда закрыта, но вам каждый раз говорят “приложите пропуск ещё раз”.

Ошибка №4: пытаться “починить” 401/403 через if в контроллере.
Контроллер — слишком поздняя точка. Правильное различение 401 и 403 происходит в security-слое раньше, чем Spring MVC выберет метод контроллера. Когда вы начинаете писать “если пользователь не тот — вернуть статус” в контроллере, вы дублируете security-логику, делаете её нецентрализованной и хрупкой. А ещё рискуете случайно открыть доступ туда, куда нельзя, потому что пропустили один из входов.

Ошибка №5: “один текст на всё”: одинаковое сообщение для 401 и 403.
Даже если формат JSON будет единый (и это хорошо), смысл сообщений должен отличаться. Для 401 сообщение должно говорить “нужна аутентификация”, для 403 — “аутентификация есть, но доступ запрещён”. Иначе клиентам и разработчикам придётся угадывать по контексту, что произошло, а угадывание — это не тот навык, который мы хотим развивать в backend-разработке.

1
Задача
Spring Security, 17 уровень, 1 лекция
Недоступна
`401` для приватного endpoint’а без аутентификации
`401` для приватного endpoint’а без аутентификации
1
Задача
Spring Security, 17 уровень, 1 лекция
Недоступна
Один endpoint с тремя исходами: `401`, `403` и `200`
Один endpoint с тремя исходами: `401`, `403` и `200`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ