401 vs 403 차이 — 인증과 인가를 구분하는 기준

401 vs 403, 둘 다 “권한 없음”처럼 보인다. 그런데 이름이 이상하다. 401 Unauthorized는 이름이 “인가 없음”처럼 읽히는데 실제로는 인증 실패 코드다. 403 Forbidden은 “접근 금지”처럼 읽히는데 실제로는 인가 실패 코드다. 이 역설 때문에 API를 짜면서 둘을 잘못 쓰는 일이 생긴다.

판단 기준은 단순하다. 서버가 요청자를 아는가, 모르는가. 모르면 401, 알지만 못 들어오게 하면 403이다.

핵심 차이: 인증 vs 인가

401 Unauthorized403 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 이름이 헷갈리더라도 이 기준만 기억하면 된다.

댓글 남기기