401 vs 403, 둘 다 “권한 없음”처럼 보인다. 그런데 이름이 이상하다. 401 Unauthorized는 이름이 “인가 없음”처럼 읽히는데 실제로는 인증 실패 코드다. 403 Forbidden은 “접근 금지”처럼 읽히는데 실제로는 인가 실패 코드다. 이 역설 때문에 API를 짜면서 둘을 잘못 쓰는 일이 생긴다.
판단 기준은 단순하다. 서버가 요청자를 아는가, 모르는가. 모르면 401, 알지만 못 들어오게 하면 403이다.
핵심 차이: 인증 vs 인가
| 401 Unauthorized | 403 Forbidden | |
|---|---|---|
| 의미 | 인증(Authentication) 실패 | 인가(Authorization) 실패 |
| 서버 입장 | “너 누구야?” | “너 누군지 알지만, 여긴 안 돼” |
| 상황 | 토큰 없음, 만료, 서명 오류 | 권한 없는 리소스 접근 |
| 클라이언트 대응 | 다시 로그인 | 권한 요청 또는 접근 포기 |
| RFC 정의 | 인증이 필요하다 | 서버가 요청을 이해했으나 거부 |
인증(Authentication)은 신원 확인이고, 인가(Authorization)는 권한 확인이다. 로그인하지 않은 상태에서 API를 호출하면 인증 실패(401)다. 로그인은 했지만 관리자 전용 페이지에 접근하려 하면 인가 실패(403)다.
쿠키, 세션, 토큰 차이를 정확히 이해하면 어떤 상황에서 인증이 실패하는지 판단하기 쉬워진다.
401 Unauthorized — 서버가 요청자를 모른다
401은 “인증이 필요하다”는 신호다. 요청에 인증 정보가 없거나 유효하지 않을 때 반환한다.
401을 쓰는 상황:
- 요청 헤더에 토큰이 없음
- JWT가 만료됨 (
TokenExpiredError) - 토큰 서명이 유효하지 않음
- 세션이 만료되거나 존재하지 않음
// Express + JWT 예시
app.get('/api/profile', (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader) {
// 토큰 자체가 없음
return res.status(401).json({
error: 'UNAUTHORIZED',
message: '로그인이 필요합니다.'
});
}
try {
const token = authHeader.split(' ')[1];
const user = jwt.verify(token, process.env.JWT_SECRET);
req.user = user;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
// 토큰 만료 — 401
return res.status(401).json({
error: 'TOKEN_EXPIRED',
message: '세션이 만료됐습니다. 다시 로그인하세요.'
});
}
// 서명 오류 등 — 401
return res.status(401).json({
error: 'INVALID_TOKEN',
message: '유효하지 않은 인증 정보입니다.'
});
}
});
HTTP 스펙(RFC 7235)에 따르면 401 응답에는 WWW-Authenticate 헤더를 함께 보내야 한다. 실무에서는 생략하는 경우가 많지만, API를 외부에 공개한다면 포함하는 게 맞다.
res.status(401)
.set('WWW-Authenticate', 'Bearer realm="api"')
.json({ error: 'UNAUTHORIZED' });
403 Forbidden — 서버가 요청자를 알지만 거부한다
403은 “인증은 됐지만 이 리소스는 허용되지 않는다”는 신호다. 토큰이 유효한데도 접근이 막히는 경우가 모두 해당된다.
403을 쓰는 상황:
- 일반 유저가 관리자 전용 API에 접근
- 다른 유저의 리소스(게시글, 파일)를 수정/삭제 시도
- 역할(role)이 없는 기능에 접근
// 역할 기반 접근 제어
app.get('/admin/users', authMiddleware, (req, res) => {
// 인증은 됐지만 관리자 권한이 없음
if (req.user.role !== 'admin') {
return res.status(403).json({
error: 'FORBIDDEN',
message: '관리자 권한이 필요합니다.'
});
}
// ...
});
// 리소스 소유권 확인
app.delete('/api/posts/:id', authMiddleware, async (req, res) => {
const post = await Post.findById(req.params.id);
if (post.authorId !== req.user.id) {
// 본인 게시글이 아님 — 인가 실패
return res.status(403).json({
error: 'FORBIDDEN',
message: '본인 게시글만 삭제할 수 있습니다.'
});
}
await post.delete();
res.status(204).send();
});
실무 판단 기준
요청이 들어왔을 때 아래 순서로 판단하면 된다.
요청 수신
│
├─ 토큰/세션이 없다 → 401
├─ 토큰이 만료됐다 → 401
├─ 토큰 서명이 유효하지 않다 → 401
│
└─ 토큰이 유효하다 → 권한 확인
├─ 해당 리소스 권한이 없다 → 403
└─ 권한 있다 → 200 / 처리
가장 흔한 실수는 JWT 만료를 403으로 처리하는 것이다. 만료된 토큰은 “누군지 모르는 상태”와 같다. 반드시 401이다.
// 잘못된 예 — 토큰 만료를 403으로 처리
if (err.name === 'TokenExpiredError') {
return res.status(403).json({ message: 'Token expired' });
}
// 올바른 예 — 토큰 만료는 401
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'TOKEN_EXPIRED',
message: '세션이 만료됐습니다.'
});
}
보안상 404를 쓰는 경우
403 대신 의도적으로 404를 반환하는 패턴이 있다. 관리자 전용 엔드포인트(/admin/dashboard)에 일반 유저가 접근했을 때 403을 반환하면 “이 경로는 존재하지만 막혀 있다”는 정보가 노출된다. 404를 반환하면 경로 자체의 존재를 숨길 수 있다.
app.get('/admin/dashboard', authMiddleware, (req, res) => {
if (!req.user.isAdmin) {
// 403 대신 404 — 경로 존재 자체를 숨김
return res.status(404).json({ error: 'NOT_FOUND' });
}
// ...
});
단, 이 패턴은 디버깅을 어렵게 만든다. 외부에 공개된 API나 보안이 민감한 관리자 경로에만 선별 적용하고, 내부 API에서는 명확하게 403을 쓰는 게 낫다.
HTTPS 없이 배포하면 토큰이 평문으로 노출되고, XSS 취약점이 있으면 토큰이 탈취될 수 있다. 401/403을 올바르게 처리하는 것만큼 토큰 자체를 안전하게 관리하는 것도 중요하다.
자주 묻는 질문
Q. Spring Security에서 401/403은 어떻게 처리하나?
Spring Security는 기본적으로 인증 실패 시 401, 인가 실패 시 403을 반환한다. 커스텀 핸들러를 등록하면 응답 형식을 바꿀 수 있다.
http.exceptionHandling()
.authenticationEntryPoint((request, response, ex) -> {
// 인증 실패 — 401
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\": \"UNAUTHORIZED\"}");
})
.accessDeniedHandler((request, response, ex) -> {
// 인가 실패 — 403
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("{\"error\": \"FORBIDDEN\"}");
});
Q. CORS 에러가 뜰 때 401/403이 함께 나온다. 어떤 걸 먼저 해결해야 하나?
CORS 에러가 있으면 브라우저가 실제 응답을 차단해서 401/403 구분이 어렵다. CORS를 먼저 해결한 뒤 인증/인가 에러를 확인하는 순서가 맞다.
Q. 401 응답을 받으면 클라이언트는 어떻게 처리해야 하나?
토큰을 삭제하고 로그인 페이지로 리다이렉트하는 게 일반적이다. Refresh Token이 있다면 먼저 토큰 재발급을 시도하고, 실패 시 로그인 페이지로 보낸다.
401 vs 403 — 401은 서버가 요청자를 모르는 것, 403은 알지만 거부하는 것이다. JWT 만료는 401, 권한 없음은 403. RFC 7235 이름이 헷갈리더라도 이 기준만 기억하면 된다.