Cloudflare Access로 내부 애플리케이션에 Zero Trust 적용하기
회사 내부용 대시보드나 관리자 페이지를 외부에 공개해야 할 때 어떻게 하시나요? 사내 VPN으로 감싸자니 외부 협업자에게 계정을 만들어 줘야 하고, IP 화이트리스트로 막자니 재택근무 환경에서 자꾸 IP가 바뀌어 곤란하죠. 그렇다고 아예 공개 URL로 두고 로그인 화면을 직접 구현하자니 인증 로직을 매번 새로 붙여야 하니 배보다 배꼽이 더 큽니다 😅
Cloudflare Access는 이런 고민을 엣지에서 해결해 주는 ZTNA(Zero Trust Network Access) 서비스입니다. 원본 애플리케이션에 손대지 않고도 요청이 Cloudflare를 거칠 때 신원을 확인하고, 정책에 맞는 사용자만 통과시키는 방식인데요. 앞서 살펴본 Cloudflare Tunnel이 “연결성”을 담당했다면, Access는 그 위에서 “누가 들어올 수 있는가”라는 인가 계층을 책임집니다.
이번 글에서는 Access가 어떤 원리로 동작하는지, 애플리케이션과 정책은 어떻게 구성하는지, 그리고 JWT 검증이나 서비스 토큰 같은 실전 요소까지 차근차근 짚어보겠습니다.
VPN 대신 ZTNA를 쓰는 이유
전통적인 VPN은 “일단 터널 안에 들어오면 내부망 사용자”라고 간주합니다. 그래서 VPN 계정이 한 번 탈취되면 공격자가 내부 자원 전체를 훑을 수 있어요. 게다가 협력 업체나 계약직에게 임시 접근을 내줄 때도 VPN 클라이언트 배포, 계정 발급, 해지 같은 과정이 번거롭습니다.
ZTNA는 이 전제를 뒤집습니다. “네트워크 안쪽 = 신뢰”라는 공식을 버리고, 요청이 들어올 때마다 누가 보냈는지, 어떤 기기에서 왔는지, 어떤 권한이 있는지를 재확인하죠. 네트워크 경계가 아닌 애플리케이션 경계에서 인가를 거는 모델이라고 보면 됩니다.
Cloudflare Access는 이 모델을 Cloudflare의 전 세계 엣지 네트워크 위에서 구현합니다. 사용자가 내 애플리케이션 URL로 접속하면 먼저 Cloudflare 엣지에 도달하고, 거기서 신원 확인과 정책 평가를 마친 뒤에야 원본 서버까지 연결이 이어집니다. 기존 애플리케이션 코드를 한 줄도 고칠 필요가 없다는 게 큰 장점이에요.
요청이 흘러가는 과정
Access가 보호하는 애플리케이션으로 요청이 들어오면 다음 순서로 처리됩니다.
- 사용자가
admin.example.com같은 보호된 도메인에 접속합니다. - Cloudflare 엣지가 요청을 가로채 로그인 화면으로 보냅니다.
- 사용자가 Google, Okta, GitHub 같은 신원 공급자(IdP)로 인증합니다.
- Access가 사용자의 신원과 기기 정보를 정책에 대입해 Allow/Block을 결정합니다.
- 통과하면 Cloudflare가 서명한 JWT를 쿠키로 발급하고, 이어지는 요청은 JWT를 검증해 통과시킵니다.
- 원본 서버에는 JWT가 담긴
Cf-Access-Jwt-Assertion헤더가 붙어 전달됩니다.
핵심은 원본 서버가 공개 인터넷에 직접 노출되지 않는다는 점인데요. Cloudflare Tunnel과 조합하면 방화벽에서 어떤 포트도 열 필요가 없고, 공개 도메인도 아예 DNS에 없어도 됩니다. 요청은 오직 Cloudflare가 인가한 경로로만 도달할 수 있죠.
애플리케이션 유형 고르기
Access에서 “애플리케이션”은 보호 대상이 되는 자원 한 덩어리를 뜻합니다. Cloudflare One 대시보드에서 애플리케이션을 추가할 때 몇 가지 유형 중에 골라야 하는데, 가장 자주 쓰는 두 가지를 짚어보겠습니다.
우선 Self-hosted는 가장 활용도가 높고 Access 배포의 대부분을 차지하는 유형입니다. 내가 트래픽을 통제할 수 있는 자원, 그러니까 Cloudflare DNS에 등록된 공개 웹사이트, Tunnel로 연결된 내부 서비스, 혹은 Cloudflare Workers에서 돌아가는 앱 모두 이 범주에 들어갑니다. 세션 관리, 애플리케이션 토큰, 재인증 강제, 기기 상태 점검, IdP 그룹 매칭까지 Access 정책 엔진을 온전히 사용할 수 있어요.
그다음으로 SaaS 애플리케이션 유형이 있습니다. Salesforce, Jira처럼 이미 벤더가 호스팅하는 SaaS에 SAML이나 OIDC SSO를 얹을 때 사용합니다. Cloudflare가 IdP 역할을 하거나 기존 IdP 앞단에서 정책을 덧붙여 준다고 생각하면 됩니다. 여러 IdP를 묶어 하나의 SSO 경험으로 만들고 싶을 때도 유용하죠.
이 글에서는 가장 쓰임새가 많은 Self-hosted 유형을 중심으로 설명하겠습니다.
Self-hosted 애플리케이션 등록하기
Cloudflare One 대시보드의 Access controls > Applications에서 애플리케이션을 추가합니다. Self-hosted를 선택하고 이름과 도메인만 넣으면 뼈대가 완성되는데, 여기서 몇 가지 결정이 필요해요.
애플리케이션 도메인은 admin.example.com처럼 정확한 호스트를 지정할 수도 있고, /admin처럼 경로를 덧붙여 특정 섹션만 보호할 수도 있습니다. 예를 들어 메인 사이트는 누구나 볼 수 있지만 /admin 이하는 내부 직원만 접근하도록 구성할 때 유용하죠.
세션 지속 시간도 중요합니다. 민감한 관리자 도구라면 짧게 두고 자주 재인증을 요구하는 게 안전하고, 반대로 직원들이 매일 들여다보는 내부 위키라면 좀 더 길게 두어 사용성을 챙기는 편이 낫습니다. 대시보드에서 분 단위부터 여러 날까지 자유롭게 고를 수 있어요.
Identity providers 항목에서는 이 애플리케이션에 사용할 IdP를 고릅니다. 여러 IdP를 동시에 활성화하면 사용자가 로그인 시점에 원하는 방법을 고를 수 있고, 반대로 하나만 활성화하고 Instant Auth를 켜면 로그인 화면을 건너뛰고 곧바로 해당 IdP로 리다이렉트됩니다. 사내용 도구처럼 단일 SSO만 써도 되는 상황이라면 Instant Auth가 훨씬 깔끔해요.
신원 공급자 연동하기
Access는 인증 자체를 수행하지 않습니다. 대신 외부 IdP의 결과를 받아 정책 판단에 사용합니다. 지원하는 IdP는 크게 세 부류로 나뉘어요.
- 소셜 IdP — Google Workspace, GitHub, Facebook 같은 OAuth 공급자. 개인 개발자나 소규모 팀에 적합합니다.
- 엔터프라이즈 IdP — Okta, Microsoft Entra ID(구 Azure AD), OneLogin, PingID 등 SAML/OIDC로 연결하는 기업용 SSO. 기업 사용자 관리와 잘 맞습니다.
- 일회용 PIN(One-time PIN) — 별도 IdP 없이 이메일로 인증 코드를 보내는 방식. 외부 협력 업체처럼 IdP 계정을 만들기 애매한 사용자에게 유용합니다.
엔터프라이즈 IdP를 연동할 때는 보통 SCIM도 같이 설정합니다. SCIM이 활성화되면 IdP에서 사용자와 그룹이 생성/삭제될 때 Access가 실시간으로 동기화해 주거든요. 신입사원 입사일에 맞춰 특정 그룹을 만들어 두고, 그 그룹에 사람만 넣으면 자동으로 권한이 적용되는 식이죠.
정책 엔진 이해하기
애플리케이션을 만든 뒤에는 반드시 한 개 이상의 정책을 붙여야 합니다. 정책은 “누가 어떤 조건일 때 통과하는가”를 결정하는 규칙 묶음인데, Action과 Rule이라는 두 축으로 구성됩니다.
Action은 정책이 매칭됐을 때 어떻게 처리할지 정합니다. Allow는 조건에 맞는 사용자를 통과시키고, Block은 조건에 맞으면 차단합니다. Bypass는 Access 검증 자체를 건너뛰어 누구나 접근하게 만들고, Service Auth는 사람이 아닌 자동화된 클라이언트(서비스 토큰 사용)만 통과시킬 때 사용하죠.
Rule은 Include, Exclude, Require 세 종류로 나뉘는데 논리 연산이 서로 다릅니다.
- Include — “이 조건 중 하나라도 맞으면 통과 대상” (OR 결합)
- Exclude — “이 조건 중 하나라도 맞으면 통과 대상에서 제외” (OR 결합)
- Require — “이 조건을 모두 만족해야 통과 대상” (AND 결합)
쉽게 말해 Include는 후보군을 넓히고, Exclude는 그 안에서 예외를 떼어내며, Require는 전체에 공통으로 걸어야 하는 필수 조건을 덧붙인다고 보면 됩니다.
예를 들어 @mycompany.com 이메일을 쓰는 직원이면서 MFA로 인증한 사람만 내부 대시보드에 접근시키되, 인턴은 빼고 싶다면 이렇게 구성합니다.
| 유형 | 셀렉터 | 값 |
|---|---|---|
| Include | Emails ending in | @mycompany.com |
| Require | Authentication method | mfa |
| Exclude | IdP group | interns |
Include가 있어야 “누구를 대상으로 하는지”가 정해지고, Require가 그 전체에 AND로 걸립니다. Exclude는 마지막에 후보를 빼는 역할이에요.
룰 그룹으로 정책 재사용하기
정책을 여러 애플리케이션에 걸쳐 쓰다 보면 비슷한 조건이 반복됩니다. “서울 오피스의 정규직” 같은 사용자 묶음을 매번 손으로 적기는 번거롭죠. Access의 Rule Group 기능은 이런 반복되는 조건을 이름 붙여 묶어두고, 정책에서는 그 그룹만 참조하게 해 줍니다.
예를 들어 seoul-fulltime이라는 룰 그룹을 만들어 “한국에서 접속” + “@mycompany.com 이메일” + “정규직 IdP 그룹” 조건을 담아두면, 이후에는 어느 애플리케이션 정책에서든 seoul-fulltime 한 덩어리만 넣어 재사용할 수 있습니다. IP 대역을 그룹으로 관리하는 것도 같은 이유로 자주 쓰이는 패턴이에요. IP가 바뀌면 그룹 한 곳만 고치면 되니까요.
JWT 검증으로 원본에서 한 번 더 확인하기
Access가 엣지에서 사용자를 통과시키고 나면, 원본 서버로 향하는 요청에는 Cf-Access-Jwt-Assertion 헤더가 붙습니다. 이 JWT 안에는 사용자 이메일, IdP 정보, 애플리케이션 식별자(AUD 태그) 같은 클레임이 담겨 있어요.
원본 서버에서 이 JWT를 검증하는 건 선택이지만, 되도록 꼭 해 두시길 권합니다. 왜냐하면 누군가 Cloudflare를 우회해 원본 서버의 실제 IP로 직접 요청을 날리면 Access 검증을 거치지 않고도 접근될 수 있거든요. JWT를 검증하면 “Cloudflare를 거친 정당한 요청”만 처리하도록 원본에서 한 번 더 방어할 수 있습니다.
Cloudflare Workers에서 Access JWT를 검증하는 예시를 보겠습니다.
import { jwtVerify, createRemoteJWKSet } from "jose";
const TEAM_DOMAIN = "mycompany.cloudflareaccess.com";
const AUD = "자기-애플리케이션의-AUD-태그";
const JWKS = createRemoteJWKSet(
new URL(`https://${TEAM_DOMAIN}/cdn-cgi/access/certs`),
);
export default {
async fetch(request) {
const jwt = request.headers.get("Cf-Access-Jwt-Assertion");
if (!jwt) {
return new Response("Access 토큰이 없습니다", { status: 401 });
}
try {
const { payload } = await jwtVerify(jwt, JWKS, {
issuer: `https://${TEAM_DOMAIN}`,
audience: AUD,
});
return new Response(`안녕하세요, ${payload.email}님`);
} catch {
return new Response("유효하지 않은 Access 토큰입니다", { status: 403 });
}
},
};
jose 라이브러리의 createRemoteJWKSet이 공개 키를 자동으로 캐시해 주기 때문에 매번 네트워크 요청이 발생하지 않습니다. issuer는 팀 도메인이고 audience는 애플리케이션마다 부여되는 AUD 태그인데, 대시보드의 애플리케이션 상세 페이지에서 확인할 수 있어요. 두 값이 모두 일치해야 이 JWT가 “해당 애플리케이션을 위해 이 팀이 발급한 것”임을 보장할 수 있습니다.
서비스 토큰으로 기계 간 인증 처리하기
사람이 아닌 스크립트나 CI/CD 파이프라인이 보호된 API를 호출해야 할 때가 있죠. 이런 자동화 주체는 IdP 로그인을 할 수 없으니 브라우저 기반 흐름으로는 통과가 불가능합니다. 이때 쓰는 게 Service Token이에요.
대시보드에서 서비스 토큰을 만들면 Client ID와 Client Secret 한 쌍이 나옵니다. 이 둘을 CF-Access-Client-Id, CF-Access-Client-Secret 헤더에 담아 요청하면 Access가 사용자 인증 없이 통과시키죠. 대신 정책에서 반드시 Service Auth 액션을 고르고 Include 룰에 Service Token 셀렉터로 발급한 토큰을 지정해야 합니다. Allow 정책에는 서비스 토큰이 매칭되지 않도록 설계돼 있거든요.
curl로 보호된 API를 호출하는 예시는 다음과 같습니다.
curl https://api.mycompany.com/health \
-H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
-H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET"
서비스 토큰은 발급 시점에 만료 기간을 정해 두고, 만료 전에 연장하거나 새로 발급해야 합니다. CI 환경 변수로 넣어둔 토큰이 조용히 만료되면 배포가 깨지니, 만료 알림을 설정해 두는 걸 권장합니다.
사설망 자원에도 Access 걸기
공개 도메인이 없는 내부 자원, 이를테면 데이터베이스 관리 UI나 Grafana 대시보드도 Access로 보호할 수 있습니다. 이 경우 Cloudflare Tunnel을 먼저 깔아 사설망과 Cloudflare 엣지를 잇고, 그 위에 공개 호스트네임을 선언한 뒤 Access 애플리케이션을 붙이면 됩니다.
사용자 입장에서는 평범한 웹사이트에 접속하는 것처럼 보이지만, 실제로는 요청이 Cloudflare 엣지 → Access 정책 검증 → Tunnel → 사설망 서버 순으로 이동합니다. 사설망 서버는 공인 IP도, 열린 포트도 없이 온전히 Cloudflare가 중개하는 경로로만 접근이 가능하죠. VPN을 걷어내고 ZTNA로 전환할 때 가장 자주 쓰이는 조합입니다.
터미널이나 SSH, RDP 같은 비-HTTP 프로토콜도 Access로 감쌀 수 있는데, 이때는 Cloudflare One Client(WARP)를 사용자 기기에 설치해 클라이언트 쪽에서 터널을 열어 주어야 합니다. 웹만 보호하는 Self-hosted 애플리케이션과 달리, 프로토콜 수준의 보호는 별도 설정이 필요하다는 점만 기억해 두세요.
자주 만나는 함정
Access를 처음 붙일 때 자주 헷갈리는 지점이 몇 가지 있는데요.
가장 흔한 문제는 Cloudflare 프록시가 꺼져 있는 경우입니다. DNS 레코드가 회색 구름(DNS only)이면 트래픽이 Cloudflare 엣지를 거치지 않으니 Access가 동작하지 않아요. 반드시 오렌지 구름(Proxied)이 활성화돼 있어야 합니다.
그다음으로 원본 서버가 실제 IP로 직접 노출돼 있으면 Access를 우회할 수 있다는 점입니다. 공격자가 IP를 알아내면 Cloudflare를 건너뛰고 바로 접근할 수 있으니, 앞서 본 JWT 검증을 원본에서 꼭 수행하거나 방화벽에서 Cloudflare IP 대역만 허용하도록 잠가야 합니다.
정책 쪽에서는 Require를 Include처럼 쓰는 실수가 잦습니다. Include가 비어 있으면 아무도 매칭되지 않아 “Require만으로는 통과 대상이 0명”이 되거든요. 정책을 만들 때는 Include에 최소한 하나의 룰을 넣어 대상 집합을 먼저 정의한다는 원칙을 지키는 게 좋습니다.
API에서 401이 떨어질 때 서비스 토큰 헤더 이름을 Authorization으로 잘못 넣는 경우도 종종 있어요. Access 서비스 토큰은 반드시 CF-Access-Client-Id와 CF-Access-Client-Secret이라는 전용 헤더를 써야 합니다. 일반 Bearer 토큰처럼 취급하면 통과되지 않죠.
마치며
Cloudflare Access는 “애플리케이션 앞에 인가 계층을 얹는다”는 단순한 아이디어를 엣지 네트워크에서 실현한 서비스입니다. VPN처럼 네트워크 전체를 열어 주는 대신, 애플리케이션 단위로 정책을 걸고 요청마다 신원을 확인하니 공격 표면이 훨씬 좁아져요.
Self-hosted 애플리케이션 하나를 등록하고 Include 룰에 이메일 도메인만 걸어도 바로 효과를 볼 수 있으니, 작게 시작해서 JWT 검증, 서비스 토큰, Rule Group으로 점차 범위를 넓혀 가는 게 현실적인 도입 경로입니다. Cloudflare Tunnel과 묶어서 쓰면 사설망 자원까지 한꺼번에 보호할 수 있으니 ZTNA로 전환을 고민 중이라면 이 조합이 강력한 출발점이 될 거예요.
더 자세한 정책 설정과 고급 기능은 Cloudflare Access 공식 문서를 참고하세요.
This work is licensed under
CC BY 4.0