Cloudflare Tunnel과 Access Service Token으로 Vercel에 집 서버 붙이기

CloudflareCloudflare AccessCloudflare TunnelNext.jsVercel인증

Vercel의 Next.js에서 Cloudflare Access 뒤에 있는 API를 fetch하면 이런 에러가 납니다.

SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON

인증 문제인데 JSON 파싱 버그처럼 보입니다.

Access는 자격증명이 없는 요청을 401로 거절하지 않고 로그인 페이지로 302 리다이렉트합니다(실측).

그런데 fetch의 redirect 기본값이 follow라 그 페이지를 따라가 200과 HTML 본문을 받습니다.

res.ok는 true이고 다음 줄의 res.json()에서 터집니다.

고치는 것은 둘입니다.

서버에서 보내는 요청에 Service Token 헤더 두 개를 붙이고, 리다이렉트를 따라가지 않게 해서 인증 실패가 인증 실패처럼 보이게 합니다.

const headers = {
  'CF-Access-Client-Id': process.env.CF_ACCESS_CLIENT_ID,
  'CF-Access-Client-Secret': process.env.CF_ACCESS_CLIENT_SECRET,
};
const res = await fetch(url, { headers, redirect: 'manual' });
if (res.status === 0 || res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) {
  throw new Error(`인증 경계에서 리다이렉트됨 — Service Token을 확인할 것`);
}

아래는 세 단(터널, Access, Service Token)이 각각 무엇을 막는지, 이 실패가 왜 그렇게 보이는지, 그리고 덤으로 그 토큰을 클라이언트 번들로 새게 만드는 type 한 단어입니다.

세 단은 각각 무엇을 막나?

백엔드는 집에 있는 맥미니에서 돌고 프론트엔드는 Vercel에 있습니다.

이 둘을 잇는 데 공유기 포트를 열지 않고 고정 IP도 사지 않는 조건을 붙였습니다.

Cloudflare로 세 단을 쌓아서 풀었습니다.

브라우저와 Vercel 서버는 같은 백엔드로 향하지만, 사람은 Access의 이메일 인증으로 Vercel 서버는 Service Token으로 서로 다른 문을 통과한다 사람 (브라우저) Vercel 서버 Service Token Cloudflare Access 터널 → 자택 서버 헤더 2개 (이메일 OTP) 이메일로 로그인 같은 문, 다른 열쇠
세 단은 서로 다른 것을 막는다 — 터널은 포트를, Access는 사람을, Service Token은 "사람이 아닌 것"을 처리한다.

1단, 터널

포트를 여는 대신 집에 있는 서버가 바깥으로 연결을 겁니다. 문서의 표현으로는 아웃바운드 전용 연결이고, 인바운드는 전부 막아도 됩니다. 공인 IP가 바뀌어도 상관없다는 것은 제 경험입니다 — 그 문서에는 없습니다. 도메인은 CNAME으로 붙습니다.

2단, Access

터널만 놓으면 그 주소는 그냥 인터넷에 열린 백엔드입니다. Access는 정책으로 누가 도달할 수 있는지를 정하고, 제 설정은 이메일 인증을 걸었습니다.

3단, Service Token

그런데 2단을 켜는 순간 내 Vercel 서버도 같이 막힙니다. 서버는 이메일함을 열어서 OTP를 읽을 수 없으니, 자동화된 시스템용 자격증명을 따로 발급해 헤더 두 개(CF-Access-Client-Id, CF-Access-Client-Secret)로 넣습니다.

여기까지는 각 제품 문서에 있는 내용이고, 문제는 그다음이었습니다.

왜 401이 아니라 JSON 파싱 에러일까?

토큰이 없으면 401이나 403이 올 거라고 생각했습니다.

확인해보면 302입니다.

$ curl -si https://api.example.com/api/health
HTTP/2 302
location: https://myteam.cloudflareaccess.com/cdn-cgi/access/login/api.example.com?...
content-type: text/html; charset=UTF-8

Access는 자격증명이 없는 요청을 거절하는 대신 로그인 페이지로 보냅니다.

브라우저에게는 옳은 동작입니다.

문제는 요청을 보낸 게 브라우저가 아니라 fetch라는 데 있습니다. 기본값이 redirect: 'follow' 라 따라가고, 그러면 200 text/html 과 <title>Sign in ・ Cloudflare Access</title> 로 시작하는 본문을 받습니다.

const res = await fetch(`${BASE}${path}`, { headers: buildHeaders() });
if (!res.ok) throw new Error(`GET ${path} → ${res.status}`);  // ← 통과한다
return res.json();                                            // ← 여기서 터진다

res.ok 는 “성공했다”가 아니라 “상태 코드가 2xx다”입니다.

리다이렉트를 따라간 뒤의 2xx도 2xx입니다. 그래서 방어선을 그냥 지나가고, 인증 문제가 JSON 파싱 버그로 위장해서 나타납니다.

에러 메시지 어디에도 Access나 토큰이라는 단어가 없습니다. 그래서 백엔드 응답 형식이나 라우팅을 의심하게 되는데, 정작 백엔드는 이 요청을 받은 적조차 없습니다.

방어는 둘 중 하나입니다. 리다이렉트를 따라가지 않게 하거나, 받은 게 JSON인지 확인하거나.

어느 쪽이든 에러 메시지에 원인 후보를 적어두는 것이 핵심이에요. 이 실패는 스택 트레이스만으로는 원인이 안 보이는 종류라서요.

덤: 그 토큰이 어디서 읽히고 있나?

두 번째는 더 조용합니다.

이 자격증명은 서버에서만 읽혀야 하는데, Next.js에서 그 경계는 lib/api.ts 파일 하나가 지키고 있습니다. 백엔드 호출을 전부 감싸고 서버 컴포넌트만 이걸 부릅니다.

그런데 클라이언트 컴포넌트 넷이 응답 타입 때문에 같은 파일을 import 합니다. 셋은 import type { PaperTrade } from '@/lib/api' 인데 하나는 import { BenchmarkNavPoint, PortfolioNav } from '@/lib/api' 였습니다.

type 한 단어가 없습니다.

지금은 둘 다 타입으로만 쓰이니 같은 결과입니다. 그런데 앞의 셋은 값을 가져올 수 없고 뒤의 하나는 가져올 수 있습니다.

문법이 강제하느냐 관행이 지키느냐의 차이입니다.

그래서 무너뜨려 봤습니다. 그 클라이언트 컴포넌트에서 값을 하나 쓰게 만들고 빌드하니 헤더 이름, 백엔드 호스트명과 API 경로, 인증 구조가 .next/static/chunks/*.js에, 즉 누구나 받아갈 수 있는 파일에 들어갔습니다. 토큰 값 자체는 들어가지 않습니다. Next.js는 NEXT_PUBLIC_ 접두사가 붙은 것만 클라이언트 번들에 값으로 심고, 브라우저의 process.env는 빈 객체라 헤더 없이 요청이 나가 아까 그 302를 브라우저에서 같은 SyntaxError로 만납니다. 자격증명 유출은 아니고 정보 노출과 고장입니다. 구분해서 말해야 대응 우선순위가 안 뒤집힙니다.

더 위험한 건 이 경계가 무너지는 순서입니다.

처음엔 값을 쓰지 않고 임포트만 추가해서 빌드했더니 아무 일도 일어나지 않았습니다. 번들러가 안 쓰는 코드를 지웠기 때문입니다. 누군가 import type에서 type을 빼도 아무 일이 안 일어나 리뷰를 통과하고, 몇 달 뒤 다른 누군가가 그 파일에서 값을 하나 쓰는 순간 백엔드 클라이언트 전체가 공개 번들로 들어갑니다. 원인과 증상 사이에 몇 달이 들어가고, 값을 쓴 사람은 자기가 무엇을 열었는지 알 방법이 없습니다. 서버 전용 경계가 관행으로 지켜지고 있다면 그건 지켜지고 있는 게 아니라 아직 안 깨진 겁니다.

그래서 빌드가 막게 했습니다. verbatimModuleSyntax(TypeScript)는 타입을 type 없이 가져오는 임포트를 잡고, 모듈 최상단의 server-only 임포트는 클라이언트에서 그 모듈의 값을 쓰는 코드를 잡습니다. 둘 다 붙여도 “값을 임포트하고 쓰지는 않는” 커밋은 여전히 안 잡힙니다. 이 조합이 막는 건 경계를 여는 커밋보다 그 구멍으로 뭔가 나가는 배포입니다. 열리는 걸 막지는 못하지만 열린 채로 나가지는 않습니다. 완전한 방어처럼 소개하면 다음 사람이 잘못된 안심을 물려받습니다. 실험 기록은 부록에 있습니다.

자주 묻는 질문

Service Token 을 안 쓰고 정책에 IP 를 넣으면 안 되나요

Vercel 서버의 출구 IP 를 고정할 수 있다면 방법이 되긴 합니다. 저는 그 IP 를 통제하지 않아서 택하지 않았습니다. Service Token 은 요청 자체가 자격을 들고 오므로 출구 주소와 무관합니다.

redirect: 'manual' 대신 응답이 JSON 인지만 보면 안 되나요

그래도 됩니다. 다만 그때는 이미 로그인 페이지 본문을 받은 뒤라서, 실패가 인증 실패가 아니라 “응답 형식이 이상함”으로 도착합니다. 어느 쪽을 쓰든 에러 메시지에 Access 와 토큰을 적어 두는 것이 핵심입니다.

토큰이 클라이언트 번들로 나간 건가요

토큰 값은 안 나갔습니다. Next.js 는 NEXT_PUBLIC_ 접두사가 붙은 것만 값으로 심습니다. 나간 것은 헤더 이름과 백엔드 호스트명, API 경로, 인증 구조입니다. 자격증명 유출이 아니라 정보 노출과 고장이고, 구분해야 대응 우선순위가 안 뒤집힙니다.

server-only 만 붙이면 되지 않나요

server-only 는 클라이언트에서 그 모듈의 값을 쓰는 코드를 잡습니다. verbatimModuleSyntax 는 타입을 type 없이 가져오는 임포트를 잡고요. 둘 다 붙여도 값을 임포트하고 쓰지는 않는 커밋은 여전히 안 잡힙니다.

참고 자료

남는 것

인증 게이트가 앞에 있으면 인증 실패는 인증 에러처럼 안 생깁니다. 리다이렉트를 따라간 200 HTML이었고, 파싱 에러로 도착했습니다. res.ok는 2xx라는 뜻일 뿐입니다.

실패 경로의 에러 메시지에 의심할 곳을 적어두세요. 이런 구조에서는 그 한 줄의 값이 특히 큽니다.

서버 전용 경계는 type 한 단어에 달려 있고, 번들러의 최적화가 그 위반을 몇 달 동안 숨겨줍니다. 세 단을 쌓는 것 자체는 각 제품 문서를 따라가면 반나절이면 됩니다. 시간을 쓴 건 전부 실패했을 때 무엇이 보이는가 쪽이었습니다.


부록 1: 리다이렉트를 따라갔을 때 받는 것

$ curl -sL -o /tmp/body -w "%{http_code} %{content_type}\n" https://api.example.com/api/health
200 text/html
<!DOCTYPE html>
<html>
  <head>
    <title>Sign in ・ Cloudflare Access</title>

부록 2: 번들 실험

클라이언트 컴포넌트에서 값을 하나 쓰게 만든 뒤 브라우저로 나가는 청크를 뒤진 결과와, 실제 토큰 값이 심기는지 표식 문자열로 확인한 결과입니다.

                       변경 전   변경 후
CF-Access-Client-Id      0         1
X-Internal-Secret        0         1
백엔드 API 경로           0         1
let eR = eP.default.env.CF_ACCESS_CLIENT_ID && eP.default.env.CF_ACCESS_CLIENT_SECRET
  ? { "CF-Access-Client-Id": eP.default.env.CF_ACCESS_CLIENT_ID, ... }
  : {};
async function eL(e) {
  let t = await fetch(`https://api.example.com${e}`, { headers: { ...eR, ...eO } });
$ grep -rl 'SENTINELCFIDAAAA' .next/static
(없음)
t.exports = (globalThis.process?.env && typeof globalThis.process?.env === 'object')
  ? globalThis.process
  : e.r(16177);   // 브라우저용 process 셰임
나가는 것 안 나가는 것
헤더 이름 (CF-Access-Client-Id) 토큰 값
백엔드 호스트명·API 경로 전체 내부 시크릿 값
인증 구조가 어떻게 생겼는지
장치 잡는 것
verbatimModuleSyntax (TypeScript) 타입을 type 없이 가져오는 임포트
server-only (모듈 최상단에 임포트) 클라이언트에서 그 모듈의 값을 쓰는 코드
이 글에서 고친 것 1개
  • 2026-09-27 — 세 단(터널, Access, Service Token)과 fetch 기본값, NEXT_PUBLIC_ 동작에 공식 문서 링크를 달았습니다. “각 제품 문서에 있다”고만 적고 링크는 없던 자리들입니다. 인증 실패가 302로 온다는 것은 문서에서 못 찾아 실측 표시를 남겼습니다.

이 사이트의 수치는 주 1회 DB와 다시 대조하고, 판단이 바뀌면 지우지 않고 글 안에 덧붙입니다. 갱신은 RSS로 받을 수 있습니다.