로그인은 됐는데 저장만 401이면 화면 세션과 요청 자격이 갈린 상태다. Bearer 헤더 누락, 만료된 액세스 토큰, 서버 액션에 안 실리는 인증부터 본다. WWW-Authenticate의 invalid_token이면 리프레시 한 번 후 재시도한다. 개발자, Next.js, 서버 액션, API, 카카오 로그인, 보안, 백엔드 기준. 2026년 9월 MDN·RFC 6750·카카오 공식 문서.
401은 로그인 화면이 깨진 게 아니라, 그 요청에 유효한 인증 정보가 없어서 서버가 거절한 응답입니다. 화면은 로그인된 것처럼 보이는데 저장만 막히면, 액세스 토큰이 끝났거나 저장 요청에 Bearer 헤더가 안 실린 칸부터 보면 됩니다.
문의 글을 고치고 저장을 눌렀는데 네트워크 탭에 401만 찍히고, 로그아웃된 것도 아닌 상황. 넥스트로 혼자 사이트를 돌리다 보면 한 번쯤 만납니다. GET은 되고 POST만 거절되면 헤더가 갈라진 경우가 많고, 카카오 로그인처럼 액세스는 짧고 리프레시는 긴 조합이면 화면 세션과 API 자격이 따로 놀죠.
화면 세션과 저장 요청의 자격이 갈린 겁니다. 브라우저가 이름과 아바타를 기억하고 있어도, 서버가 그 POST에서 읽은 토큰은 없거나 이미 끝난 값일 수 있습니다.
MDN은 401을 "요청한 자원에 유효한 인증 정보가 없어 실패한 클라이언트 오류"라고 적습니다. 서버는 보통 WWW-Authenticate 헤더로 어떤 방식(예: Bearer)을 원하는지 알려 줍니다. 로그인 페이지가 200을 준 것과, 저장 API가 401을 준 것은 다른 요청입니다. 전자는 세션 쿠키나 사용자 객체를 그렸고, 후자는 액세스 토큰을 검사한 거죠.
저는 한국에서 넥스트 사이트를 여러 개 혼자 돌리며, 카카오 로그인 직후 문의 저장이 한동안 되다가 오후에만 막히는 패턴을 자주 봤습니다. 화면은 그대로인데 저장만 거절되면 로그아웃부터 누르지 마세요. 네트워크 탭에서 실패한 요청의 요청 헤더에 Authorization이 있는지, 응답 헤더에 WWW-Authenticate가 있는지를 먼저 봅니다.
보이는 증상
먼저 볼 칸
흔한 원인
GET 목록은 되고 저장 POST만 401
POST의 Authorization 헤더
목록은 쿠키, 저장은 Bearer를 기대
오전까지 되다가 오후에만 401
액세스 만료 초, 리프레시 여부
액세스는 끝났고 화면 상태는 남음
라우트 핸들러는 되고 서버 액션만 401
액션 요청에 실린 쿠키
메모리의 Bearer가 액션에 안 실림
로컬은 되고 프리뷰만 401
쿠키 도메인, 시크릿, 리다이렉트 URI
다른 출처라 쿠키가 안 따라감
로컬과 프리뷰가 갈리면 인증 방식보다 쿠키 도메인 쪽을 의심합니다. 그건 쿠키 SameSite 글 자리입니다. 여기는 같은 출처에서 저장만 거절되는 칸만 봅니다.
먼저 기억할 것 | 401을 로그아웃으로 처리하기 전에, 실패한 요청에 Bearer가 실렸는지와 응답의 error="invalid_token" 여부를 읽으세요. 헤더가 비어 있으면 만료가 아니라 전달 누락입니다.
401과 403과 로그인 리다이렉트는 어디가 다른가
401은 자격이 없거나 틀렸고, 403은 자격은 있는데 권한이 없습니다. 로그인 페이지로 보내는 302는 상태 코드가 아예 다릅니다.
MDN도 401과 403을 이렇게 가릅니다. 403은 유효한 자격이 있는데도 그 동작을 할 권한이 없을 때입니다. 저장 API가 401이면 "너가 누구인지 모르겠다"에 가깝고, 403이면 "누군지는 알겠는데 이 글은 못 고친다"에 가깝습니다. 관리자만 쓸 수 있는 경로를 일반 로그인이 두드리면 403이 맞고, 토큰이 빠졌으면 401이 맞습니다.
프론트가 401을 받아 로그인 페이지로 보내는 것과, 서버가 302로 로그인 URL을 주는 것도 다릅니다. 에이전트나 fetch는 302를 따라가 HTML을 받을 수 있고, JSON을 기대한 코드는 파싱에서 한 번 더 넘어집니다. API라면 401을 그대로 주고, 화면 이동은 클라이언트가 결정하는 편이 덜 헷갈립니다.
응답
의미
다음에 할 일
401
인증 정보가 없거나 무효
헤더 확인, 만료면 리프레시 한 번
403
인증은 됐으나 권한 없음
역할, RLS, 스코프. 토큰 재발급으로 안 풀림
302 로그인 URL
브라우저 흐름용 리다이렉트
API fetch가 따라가면 HTML을 받음
429
호출이 많아 기다리라는 뜻
인증과 무관. 백오프 글 자리
429와 자리를 섞지 마세요. 짧은 시간에 호출이 몰려 기다리라는 응답은 429 백오프 글에서 다룹니다. 교차 출처 헤더가 막힌 건 CORS 프리플라이트 글입니다.
로그인은 유지된 채 저장 POST만 401이면 그 요청의 인증 헤더부터 본다
액세스 토큰과 리프레시 토큰이 갈리는 칸
액세스는 짧은 호출용이고, 리프레시는 액세스를 다시 받는 열쇠입니다. 화면이 로그인처럼 보여도 액세스가 끝나면 저장은 401이 납니다.
RFC 6750은 보호된 자원에 액세스 토큰을 Authorization: Bearer 헤더로 보내라고 적습니다. 만료·폐기·잘못된 토큰이면 자원 서버는 401과 함께 error="invalid_token"을 줄 수 있습니다. 문서 예시의 설명 문구는 "The access token expired"입니다. 이 신호면 리프레시로 액세스를 갈아끼운 뒤 같은 요청을 한 번 더 보내면 됩니다. 리프레시까지 끝났으면 그때 로그인 화면으로 보내세요.
카카오 로그인 REST 토큰 응답에는 expires_in(액세스와 ID 토큰 만료 초)과 refresh_token_expires_in이 따로 옵니다. 공식 예시 응답은 액세스가 43199초(약 12시간), 리프레시가 5184000초(약 60일)입니다. 오픈아이디를 켠 다른 예시는 액세스가 7199초(약 2시간)로 찍히기도 합니다. 숫자를 코드에 박지 말고, 발급 응답의 초를 저장해 만료 직전에 갱신하세요.
칸
어디에 두나
끝나면
액세스 토큰
짧은 메모리 또는 httpOnly 쿠키
401 invalid_token → 리프레시
리프레시 토큰
서버 또는 httpOnly 쿠키만
다시 로그인
화면 사용자 객체
상태, 로컬 저장소
자격과 무관. 남아 있어도 저장은 실패
리프레시를 브라우저 로컬 저장소에 두면 XSS 한 번에 장기 자격이 나갑니다. 한국 1인 개발자가 카카오 로그인을 붙일 때 자주 하는 실수예요. 액세스는 호출 헤더로만 쓰고, 리프레시는 서버가 들고 있는 편이 안전합니다.
401 응답에서 invalid_token만 골라 읽기
function bearerError(wwwAuthenticate) {
const m = /error="([^"]+)"/.exec(wwwAuthenticate || '');
return m ? m[1] : '';
}
// RFC 6750 예시:
// WWW-Authenticate: Bearer realm="example",
// error="invalid_token",
// error_description="The access token expired"
const err = bearerError(res.headers.get('WWW-Authenticate'));
if (res.status === 401 && err === 'invalid_token') {
// 리프레시 한 번 후 같은 요청 재시도
}
서버 액션에 Bearer가 안 붙는 이유
서버 액션은 같은 출처 쿠키만 따라가고, Authorization 헤더는 직접 안 싣습니다. 클라이언트 fetch에 Bearer를 붙이던 습관을 액션에 그대로 옮기면 저장만 401이 납니다.
넥스트 서버 액션은 브라우저가 Next-Action 같은 필드로 POST를 보냅니다. 같은 출처 쿠키는 자동으로 따라가지만, 메모리에만 있는 액세스 토큰은 헤더로 안 붙습니다. 그래서 목록은 fetch('/api/...')에 Bearer를 실어 200이 나고, 저장 버튼이 서버 액션이면 서버의 cookies()에 액세스가 없어 401이 납니다.
고치는 방법은 둘 중 하나입니다. 액세스를 httpOnly 쿠키로 두고 액션 안에서 cookies().get으로 읽거나, 저장도 라우트 핸들러로 보내 클라이언트에서 Bearer를 붙입니다. 액션 앞단에서 로그인 여부만 막는 프록시는 프록시 매처 글 자리이고, 제출 중 버튼을 잠그는 건 폼 상태 훅 글 자리입니다. 여기서는 액션이 읽는 자격이 비었는지 만 봅니다.
넥스트 14 기준 | cookies()는 동기입니다. 15부터 비동기로 바뀌어 await cookies()를 씁니다. 이 저장소는 14.2라 동기 호출이 맞습니다. 버전을 올렸다면 공식 문서의 함수 시그니처를 한 번 더 보세요.
서버 액션은 메모리 Bearer가 아니라 쿠키에 있는 액세스만 읽는다
만료를 읽고 한 번만 갱신하는 순서
WWW-Authenticate의 invalid_token을 보고, 리프레시는 한 번만 겹치지 않게 돌립니다. 401마다 리프레시를 남발하면 토큰 엔드포인트가 429로 돌아옵니다.
순서는 짧습니다. 실패한 응답이 401인지 확인하고, WWW-Authenticate에서 invalid_token인지 읽고, 리프레시 한 줄을 잠금(한 번에 하나)으로 돌린 뒤, 새 액세스로 같은 요청을 한 번만 재시도합니다. 리프레시가 실패하면 로그인 화면으로 보냅니다. 헤더가 없고 Authorization도 비어 있으면 갱신이 아니라 전달 누락입니다. 그 경우 리프레시를 돌려 봐야 같은 401이 납니다.
동시 저장이 두 개면 둘 다 401을 받고 둘 다 리프레시를 부릅니다. 한 번은 성공하고 한 번은 이미 폐기된 리프레시를 쓰게 됩니다. 한국에서 크론과 웹훅이 같은 키를 쓰는 실수도 비슷한 모양이라, 갱신 함수를 프로세스당 하나로 묶어두세요.
아닙니다. 먼저 그 요청에 Bearer가 실렸는지, 응답 WWW-Authenticate에 invalid_token이 있는지를 보세요. 헤더가 비었으면 전달 누락이고, invalid_token이면 리프레시 한 번 후 재시도하면 됩니다. 리프레시까지 실패했을 때만 로그인 화면으로 보내세요.
401과 403을 같이 재시도해도 되나요?
안 됩니다. 403은 자격은 있는데 권한이 없는 응답이라, 토큰을 갈아끼워도 같은 거절이 납니다. 역할이나 스코프, 슈퍼베이스 정책을 봐야 합니다. 401만 만료 갱신 대상입니다.
서버 액션 저장만 401입니다. fetch API는 됩니다.
액션은 메모리의 Bearer를 자동으로 안 붙입니다. 액세스를 httpOnly 쿠키에 두고 cookies()로 읽거나, 저장도 라우트 핸들러로 보내 클라이언트에서 헤더를 붙이세요. 넥스트 14는 cookies()가 동기입니다.
카카오 로그인은 됐는데 오후에 저장이 막힙니다.
액세스 만료 초와 화면 세션이 따로 놉니다. 카카오 토큰 응답의 expires_in을 저장해 만료 전에 갱신하세요. 공식 예시는 액세스가 약 2시간 또는 약 12시간, 리프레시가 약 60일로 찍힙니다. 숫자를 코드에 고정하지 말고 발급 응답을 믿으세요.
WWW-Authenticate가 없으면 어떻게 하나요?
RFC는 401에 이 헤더를 보내라고 하지만, 실제 API는 본문 JSON만 주는 경우도 있습니다. 그때는 본문의 error 코드가 expired인지 읽고, 없으며 Authorization 누락부터 의심하세요. 헤더가 없다고 무조건 리프레시하면 전달 버그를 덮습니다.
프리플라이트나 SameSite 문제와 어떻게 구분하나요?
다른 출처에서 OPTIONS가 막히면 CORS이고, 사파리에서 쿠키가 안 붙으면 SameSite입니다. 같은 출처에서 상태 코드가 401이면 이 글의 자격 칸입니다. 교차 출처는 CORS 글, 쿠키 전송은 SameSite 글을 보세요.
401은 그 요청에 유효한 인증 정보가 있는가의 문제입니다. 화면 로그인과 저장 자격을 나누어 보고, Bearer 누락이면 전달 경로를 고치고, invalid_token이면 리프레시 한 번 후 재시도하세요. 관련 글: 429 백오프, CORS, 쿠키 SameSite.