Firebase 보안 규칙 가이드: 흔한 실수 3가지와 고친 규칙
Firebase 보안 규칙을 테스트 모드나 request.auth 확인만으로 출시하면 생기는 문제와 Firestore·Storage 규칙 수정법, 에뮬레이터 테스트 코드, 출시 전 체크리스트를 정리했습니다.
Firebase 앱의 데이터를 지키는 것은 API 키를 숨기는 일이 아니라 보안 규칙(Security Rules)입니다. 웹 번들이나 앱 패키지에 들어간 firebaseConfig는 누구나 볼 수 있습니다. 그래서 규칙이 허용하는 요청은 우리 앱 화면을 거치지 않고 SDK나 REST로 직접 보내도 그대로 처리됩니다. 출시 전에는 세 가지를 확인하면 됩니다. 테스트 모드 규칙(allow read, write: if true 또는 날짜 조건)이 남아 있지 않은지, request.auth != null만 보고 다른 사람의 문서까지 열어 두지 않았는지, 쓰기 요청에서 사용자가 바꿀 수 있는 필드를 제한했는지입니다.
이 글은 이 세 가지 실수를 취약한 규칙과 고친 규칙으로 나란히 비교합니다. 그리고 고친 규칙이 실제로 막히는지 로컬 에뮬레이터 테스트 코드로 확인하는 방법까지 다룹니다. Firestore를 중심으로 설명하고, Cloud Storage 규칙도 함께 짚습니다.
보안 점검을 하다 보면 Firebase 프로젝트에서 나오는 문제는 대부분 규칙 문법보다 "누가 이 요청을 보낼 수 있는가"라는 전제를 잘못 잡은 데서 시작합니다. 그 전제부터 정리하고 들어가겠습니다.
전제: 클라이언트 설정값은 공개되어 있다
Firebase 공식 문서도 Firebase 서비스용 API 키는 비밀이 아니라고 설명합니다. 클라이언트 코드나 저장소에 들어가도 괜찮고, 백엔드 리소스에 대한 접근 제어는 보안 규칙과 App Check가 맡는다는 것입니다. 다만 키별로 사용할 수 있는 API를 제한하는 것은 여전히 권장합니다.
여기서 나오는 결론은 단순합니다.
- 공격자는 우리 앱의 projectId와 API 키를 이미 알고 있다고 가정합니다.
- 화면에서 버튼을 숨기거나 클라이언트 코드에서
if (user.role === 'admin')으로 분기하는 것은 보안 장치가 아닙니다. - 규칙이 허용하는 모든 요청은 실제로 들어올 수 있습니다. 규칙만 읽고 "이 조건이면 누가 무엇을 할 수 있는가"를 설명할 수 있어야 합니다.
Supabase의 anon 키도 같은 구조입니다. 비교해 보고 싶다면 Supabase RLS 보안 가이드를 참고하세요.
실수 1: 테스트 모드 규칙을 그대로 출시
콘솔에서 데이터베이스를 처음 만들 때 테스트 모드를 고르면 모든 읽기·쓰기를 허용하는 규칙이 들어갑니다. 보통 형태는 다음과 같습니다.
// ❌ 취약: 날짜가 지나기 전까지 누구나 전체 DB를 읽고 쓸 수 있음
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if request.time < timestamp.date(2026, 12, 1);
}
}
}
날짜 조건이 있으니 안전하다고 생각하기 쉽지만, 그 날짜까지는 if true와 다르지 않습니다. 날짜가 지나 앱이 동작하지 않으면 날짜만 뒤로 미루는 일도 흔합니다. 공식 문서는 if true 규칙을 두고 프로덕션에서 절대 쓰지 말라고 분명히 경고합니다.
조금 덜 알려진 함정도 있습니다. Firestore에서는 같은 문서에 여러 match가 걸리면 하나라도 허용할 때 접근이 허용됩니다. 컬렉션별 규칙을 꼼꼼히 써 놓아도 맨 위의 match /{document=**} 테스트 규칙이 남아 있으면 그 규칙들은 의미가 없어집니다.
// ✅ 고친 방향: 기본은 전부 거부하고, 필요한 경로만 명시적으로 연다
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// 와일드카드 허용 규칙은 두지 않는다.
// 아래에 명시하지 않은 경로는 자동으로 거부된다.
match /users/{userId} { /* 실수 2·3에서 채움 */ }
match /orders/{orderId} { /* 실수 2에서 채움 */ }
}
}
판단 기준은 이렇습니다. 배포된 규칙 파일에 {document=**}가 보이면 그 블록의 조건을 한 줄씩 읽어 보세요. 전체 DB에 적용해도 괜찮은 조건이 아니라면 지웁니다.
실수 2: request.auth만 확인하는 규칙
테스트 모드를 걷어낸 뒤 가장 많이 쓰는 규칙이 바로 이것입니다.
// ❌ 취약: 로그인만 하면 남의 주문도 읽고 수정할 수 있음
match /orders/{orderId} {
allow read, write: if request.auth != null;
}
Firebase Authentication은 대부분 누구나 가입할 수 있습니다. 익명 로그인을 켜 두었다면 가입 절차도 필요 없습니다. 결국 이 규칙은 "인터넷의 누구나"에서 "계정을 하나 만든 누구나"로 범위를 조금 줄였을 뿐입니다. 공식 문서도 auth != null이 들어간 규칙이 있다면 정말 모든 로그인 사용자에게 접근을 허용하려는 것인지 확인하라고 안내합니다. 구조는 IDOR 취약점과 같습니다. 인증은 했지만 인가는 하지 않은 상태입니다.
// ✅ 고친 규칙: 문서 소유자만 접근
match /orders/{orderId} {
allow read: if request.auth != null
&& resource.data.ownerId == request.auth.uid;
allow create: if request.auth != null
&& request.resource.data.ownerId == request.auth.uid;
// 주문 수정·삭제는 서버(Admin SDK, Cloud Functions)에서만
allow update, delete: if false;
}
두 가지를 함께 기억해 두세요.
- 규칙은 필터가 아닙니다. 클라이언트가
orders전체를 조회하면 규칙이 "내 것만 골라서" 돌려주지 않고 쿼리 자체가 거부됩니다. 그래서 클라이언트 쿼리도where('ownerId', '==', uid)처럼 규칙과 같은 조건을 걸어야 합니다. resource와request.resource를 구분합니다.resource는 저장된 기존 문서이고,request.resource는 쓰기가 끝난 뒤의 문서 모습입니다. create에서는request.resource의 소유자 필드를 검사합니다. 그러지 않으면ownerId에 남의 uid를 넣은 문서를 만들 수 있습니다.
관리자 기능은 문서 안의 role 필드보다 서버에서 설정하는 커스텀 클레임(request.auth.token.admin == true)으로 판단하는 편이 안전합니다. 문서 필드는 실수 3처럼 사용자가 바꿀 수 있기 때문입니다.
실수 3: 필드 단위 쓰기 검증 누락
소유자 확인까지 해 두었는데도 자주 뚫리는 곳이 "내 문서는 내가 마음대로 쓸 수 있다"는 규칙입니다.
// ❌ 취약: 본인 문서라면 어떤 필드든 쓸 수 있음
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
화면에는 닉네임 수정 기능만 있더라도, 요청을 직접 보내면 role: 'admin', plan: 'pro', points: 999999 같은 필드를 함께 쓸 수 있습니다. 이 필드를 다른 규칙이나 서버 로직이 믿고 쓰고 있다면 권한 상승으로 이어집니다.
공식 문서가 권하는 방법은 바꿔도 되는 필드를 허용 목록으로 지정하는 것입니다. 생성할 때는 keys().hasOnly()로 들어갈 수 있는 필드를 제한합니다. 수정할 때는 diff().affectedKeys().hasOnly()로 이번 요청에서 바뀌는 필드만 검사합니다. update에서 request.resource.data.keys()는 기존 필드까지 포함한 전체 문서이므로, 변경된 필드를 확인하려면 diff를 써야 합니다.
// ✅ 고친 규칙: 생성·수정 시 허용 필드와 타입을 검증
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isOwner(uid) {
return request.auth != null && request.auth.uid == uid;
}
function validName(name) {
return name is string && name.size() > 0 && name.size() <= 30;
}
match /users/{userId} {
allow read: if isOwner(userId);
allow create: if isOwner(userId)
&& request.resource.data.keys().hasOnly(['displayName', 'photoURL', 'createdAt'])
&& validName(request.resource.data.displayName)
&& request.resource.data.createdAt == request.time;
allow update: if isOwner(userId)
&& request.resource.data.diff(resource.data)
.affectedKeys().hasOnly(['displayName', 'photoURL'])
&& validName(request.resource.data.displayName);
allow delete: if false;
}
}
}
role, plan, points처럼 서버가 책임지는 필드는 허용 목록에 넣지 않습니다. Admin SDK는 규칙을 우회하므로 Cloud Functions나 백엔드에서 결제 확인 같은 검증을 거친 뒤 씁니다. 금액이나 포인트를 클라이언트가 정하게 두면 결제 금액 변조와 같은 문제가 생깁니다.
Cloud Storage 규칙: 경로·크기·형식을 함께 본다
Storage도 패턴은 같습니다. 공식 문서의 취약 예시는 allow read, write: if request.auth != null;이고, 권장 형태는 경로의 userId를 request.auth.uid와 비교하는 것입니다. 여기에 업로드 크기와 형식 제한을 더하면 다음과 같습니다.
// ✅ 사용자별 아바타 업로드 규칙
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /users/{userId}/avatar/{fileName} {
allow read: if request.auth != null && request.auth.uid == userId;
allow create, update: if request.auth != null
&& request.auth.uid == userId
&& request.resource.size < 2 * 1024 * 1024
&& request.resource.contentType.matches('image/(png|jpeg|webp)');
allow delete: if request.auth != null && request.auth.uid == userId;
}
}
}
contentType은 업로드하는 쪽이 정하는 메타데이터입니다. 규칙은 1차 필터일 뿐 파일 내용을 보증하지 않습니다. 업로드된 파일을 다른 사용자에게 보여 주거나 서버에서 처리한다면 파일 업로드 취약점에서 다룬 서버 측 검증도 함께 설계하세요.
에뮬레이터로 규칙을 테스트하는 법
규칙을 고쳤다면 "막혀야 할 요청이 정말 막히는지"를 코드로 남겨 두는 것이 좋습니다. 공식 패키지 @firebase/rules-unit-testing은 로컬 에뮬레이터 위에서 사용자별 컨텍스트를 만들어 줍니다. assertFails는 권한 거부를, assertSucceeds는 성공을 확인합니다.
// firestore.rules.test.js (Vitest/Jest 공통 형태)
import { readFileSync } from 'node:fs';
import {
initializeTestEnvironment, assertFails, assertSucceeds,
} from '@firebase/rules-unit-testing';
import { doc, getDoc, setDoc, updateDoc, serverTimestamp } from 'firebase/firestore';
let testEnv;
beforeAll(async () => {
testEnv = await initializeTestEnvironment({
projectId: 'demo-rules-test',
firestore: { rules: readFileSync('firestore.rules', 'utf8') },
});
});
beforeEach(async () => {
await testEnv.clearFirestore();
// 규칙을 끈 상태로 테스트 데이터 준비
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), 'users/alice'), {
displayName: 'alice', role: 'user',
});
});
});
afterAll(() => testEnv.cleanup());
test('비로그인 사용자는 프로필을 읽을 수 없다', async () => {
const anon = testEnv.unauthenticatedContext().firestore();
await assertFails(getDoc(doc(anon, 'users/alice')));
});
test('로그인했어도 다른 사람 프로필은 읽을 수 없다', async () => {
const bob = testEnv.authenticatedContext('bob').firestore();
await assertFails(getDoc(doc(bob, 'users/alice')));
});
test('본인 프로필의 닉네임은 수정할 수 있다', async () => {
const alice = testEnv.authenticatedContext('alice').firestore();
await assertSucceeds(updateDoc(doc(alice, 'users/alice'), { displayName: 'alice2' }));
});
test('본인이라도 role 필드는 바꿀 수 없다', async () => {
const alice = testEnv.authenticatedContext('alice').firestore();
await assertFails(updateDoc(doc(alice, 'users/alice'), { role: 'admin' }));
});
test('허용되지 않은 필드를 넣어 생성할 수 없다', async () => {
const carol = testEnv.authenticatedContext('carol').firestore();
await assertFails(setDoc(doc(carol, 'users/carol'), {
displayName: 'carol', createdAt: serverTimestamp(), plan: 'pro',
}));
});
실행은 에뮬레이터를 띄운 상태에서 하면 됩니다. CI에서는 아래처럼 한 줄로 묶을 수 있습니다.
firebase emulators:exec --only firestore "npm test"
테스트는 사용자 유형마다 최소 네 가지를 갖추면 실무에서 충분히 쓸 만합니다. 비로그인, 다른 사용자, 본인, 본인이지만 금지된 필드 변경입니다. 앞의 세 실수를 고친 규칙이 이 테스트들을 통과하는지 확인하고, 규칙을 바꿀 때마다 CI에서 다시 돌리세요. 테스트 모드 규칙을 넣으면 assertFails 테스트가 바로 실패하므로 회귀를 잡는 장치도 됩니다.
출시 전 Firebase 보안 규칙 체크리스트
- 배포된 Firestore·Storage·Realtime Database 규칙에
if true, 날짜 조건 규칙, 와일드카드 허용 규칙이 없다 - 콘솔에서 고친 규칙을
firestore.rules,storage.rules파일로 저장소에 두고, 배포도 파일 기준으로 한다 -
request.auth != null만 쓰는 규칙마다 "모든 가입자에게 열려도 되는 데이터인가"를 확인했다 - 사용자 소유 문서는 경로의 uid나
ownerId필드를request.auth.uid와 비교한다 - create는
keys().hasOnly(), update는diff().affectedKeys().hasOnly()로 허용 필드를 제한한다 - 권한·요금제·포인트·금액 필드는 클라이언트가 쓸 수 없고, 서버(Admin SDK)에서만 쓴다
- 관리자 판단은 문서 필드가 아니라 커스텀 클레임으로 한다
- 클라이언트 쿼리 조건이 규칙 조건과 같다(규칙은 필터가 아님)
- Storage 업로드에 크기·형식 제한이 있고, 다른 사용자 경로에 쓸 수 없다
- 익명 로그인을 켰다면 익명 사용자가 할 수 있는 일을 따로 검토했다
- 에뮬레이터 규칙 테스트(비로그인/타인/본인/금지 필드)가 CI에서 돈다
- Firebase API 키에 사용 가능한 API 제한을 걸었다
API 키와 다른 비밀값을 함께 점검하려면 API 키 유출 방지 체크리스트도 참고하세요.
자주 묻는 질문
App Check를 켜면 보안 규칙을 느슨하게 해도 되나요?
아닙니다. App Check는 요청이 정상 앱에서 왔을 가능성을 높여 주는 장치입니다. 사용자가 그 데이터에 접근할 권한이 있는지는 판단하지 않습니다. 정상 앱으로 로그인한 사용자도 다른 사람의 문서를 요청할 수 있으므로, 소유자 검증과 필드 검증은 규칙에서 해야 합니다.
Cloud Functions(Admin SDK)에서 쓰는 데이터도 규칙의 영향을 받나요?
받지 않습니다. Admin SDK는 보안 규칙을 우회합니다. 그래서 서버만 쓰는 컬렉션은 규칙에서 allow read, write: if false;로 닫아 두면 됩니다. 반대로 Functions 안의 권한 검증은 규칙이 대신해 주지 않으므로 함수 코드에서 직접 해야 합니다.
이미 테스트 모드로 운영 중이었다면 무엇부터 해야 하나요?
먼저 규칙을 최소 권한으로 바꿔 배포합니다. 그다음 테스트 모드 기간 동안 권한 필드나 금액 필드에 이상한 값이 들어간 문서가 없는지 데이터를 점검합니다. 개인정보가 담긴 컬렉션이 열려 있었다면 유출 여부를 판단하고 대응해야 할 수 있으니, 사내 개인정보 담당자와 함께 검토하세요.
정리
- Firebase 설정값과 API 키는 공개된다고 보고, 모든 접근 제어를 보안 규칙에서 판단합니다.
- 테스트 모드와 와일드카드 허용 규칙을 걷어내고,
request.auth != null대신 소유자 비교를 씁니다. - 쓰기 규칙은
hasOnly와affectedKeys로 바꿀 수 있는 필드를 허용 목록으로 제한하고, 권한·금액 필드는 서버에서만 씁니다. - 고친 규칙은 에뮬레이터 테스트로 비로그인, 다른 사용자, 금지 필드 시나리오까지 확인하고 CI에 넣습니다. 직접 점검이 어렵다면 무료 분석 신청으로 현재 규칙을 함께 살펴볼 수 있습니다.
참고 자료
- Firebase 공식 문서 - Avoid insecure rules
- Firebase 공식 문서 - Learn about using and managing API keys for Firebase
- Firebase 공식 문서 - Control access to specific fields (Cloud Firestore)
- Firebase 공식 문서 - Build unit tests (Security Rules)
- Firebase 공식 문서 - rules-unit-testing package reference
- Firebase 공식 문서 - Cloud Storage Security Rules
함께 읽으면 좋은 글
AWS 보안 설정 가이드: 초기 스타트업이 하루에 잠글 5가지
AWS 보안 설정을 루트 계정, IAM, S3, 보안 그룹, 감사 로그 순으로 정리했다. 소규모 팀이 하루 안에 점검하고 고칠 수 있는 명령어와 수정 전후 예시, 판단 기준을 담았다.
취약점 점검 리포트 읽는 법: 위험도 해석부터 조치 우선순위까지
취약점 점검 리포트의 위험도를 서비스 맥락으로 해석하고, 조치 순서 결정, 오탐 확인, 재점검 요청, 수정 완료 근거 정리까지 개발팀의 처리 흐름을 안내합니다.
개인정보 유출 사고 사례: 개인정보위 처분에서 반복된 원인 5가지
개인정보 유출 사고 사례를 개인정보위 처분 기준으로 분석해 반복된 원인 5가지를 정리하고, 개발팀이 바로 쓸 코드 예시와 점검 항목으로 바꿔 줍니다.