Firebase 보안 규칙은 경로·작업·소유권을 함께 설계해야 합니다
Firebase Authentication으로 로그인했더라도 모든 데이터 접근을 허용해서는 안 됩니다. Firestore와 Cloud Storage의 각 경로에서 누가 읽고 쓸 수 있는지, 어떤 작업과 필드만 허용할지, 쿼리와 업로드가 그 조건을 어떻게 증명할지를 최소 권한 원칙으로 설계해야 합니다. 마지막에는 Local Emulator Suite에서 허용 사례와 거부 사례를 모두 자동 테스트한 뒤 규칙 파일만 배포합니다.
문서 기준: 이 글은 Firebase 공식 문서의 Firestore Security Rules, Cloud Storage Security Rules, Local Emulator Suite 내용을 2026-07-19에 확인해 작성했습니다.
- 보안 경계: 인증과 권한 부여를 분리하기
- 최소 권한: 경로와 작업 표부터 만들기
- Firestore: 소유권과 필드 무결성 검사하기
- 쿼리와 규칙: 같은 조건을 증명하기
- Storage: 사용자 경로와 파일 조건 결합하기
- Emulator Suite: 성공과 실패를 회귀 테스트하기
- 배포 전후 체크리스트
- 자주 생기는 설계 실수
- 공식 문서와 관련 글
- 결론: 규칙을 운영 절차로 만들기
보안 경계: 인증과 권한 부여를 분리하기

인증은 요청자가 누구인지 확인하는 단계이고, 권한 부여는 그 사용자가 특정 데이터에 어떤 작업을 할 수 있는지 판단하는 단계입니다. Firestore와 Storage 규칙에서는 보통 request.auth로 인증 상태와 UID를 확인한 뒤, 문서의 소유자 필드 또는 파일 경로의 UID와 비교합니다. 로그인 여부만 검사하는 규칙은 모든 로그인 사용자에게 같은 권한을 주므로 개인 데이터 보호에는 충분하지 않습니다.
클라이언트 요청은 규칙 평가를 통과해야 합니다
모바일·웹 클라이언트가 Firestore에 보내는 요청은 배포된 Security Rules와 대조됩니다. 일치하는 allow 조건이 없거나 조건이 거짓이면 요청은 거부됩니다. 따라서 화면에서 버튼을 숨기거나 UID를 전달하는 것만으로는 권한을 보호할 수 없습니다. 최종 판단은 항상 서버에서 적용되는 규칙에 있어야 합니다.
| 검사 대상 | 규칙에서 보는 값 | 대표 질문 |
|---|---|---|
| 요청자 | request.auth, request.auth.uid |
로그인했는가, 이 UID가 소유자인가? |
| 기존 Firestore 문서 | resource.data |
현재 저장된 소유자는 누구인가? |
| 쓰기 이후 Firestore 문서 | request.resource.data |
저장될 값이 소유권과 스키마를 지키는가? |
| Storage 업로드 | 경로 변수, request.resource |
자기 경로인가, 크기와 콘텐츠 유형이 허용되는가? |
서버 SDK에는 별도의 권한 경계가 필요합니다
Firestore 서버 클라이언트 라이브러리는 Security Rules를 우회하고 Google Application Default Credentials와 IAM으로 인증합니다. 따라서 클라이언트 규칙이 엄격해도 신뢰 서버의 서비스 계정 권한이 지나치게 넓으면 전체 경계가 약해집니다. 클라이언트 규칙과 서버 IAM을 같은 것으로 보지 말고, 서버 계정에도 필요한 역할만 부여해야 합니다. 자세한 경계는 Firestore Security Rules 시작 문서에서 확인할 수 있습니다.
최소 권한: 경로와 작업 표부터 만들기
규칙 파일부터 작성하면 화면 기능을 따라가며 예외가 계속 늘어나기 쉽습니다. 먼저 실제 데이터 경로를 나열하고, 각 경로에서 비로그인 사용자·소유자·다른 로그인 사용자·신뢰 서버가 할 수 있는 작업을 표로 정리하는 편이 안전합니다. 기본값은 거부이며, 제품 요구사항으로 설명할 수 있는 권한만 추가합니다.
읽기와 쓰기를 세부 작업으로 나눕니다
Firestore의 read는 get과 list로, write는 create, update, delete로 나눌 수 있습니다. Storage도 read를 get과 list로, write를 create, update, delete로 세분화할 수 있습니다. 한 문서 조회는 필요하지만 전체 목록은 불필요한 경로라면 get만 허용하는 식으로 권한 폭을 줄입니다.
| 경로 | 비로그인 | 소유자 | 다른 사용자 |
|---|---|---|---|
profiles/{userId} |
거부 | 단건 조회·생성·허용 필드 수정·삭제 | 거부 |
posts/{postId} |
공개 글 조회만 | 자기 글 조회·생성·수정·삭제 | 공개 글 조회만 |
users/{userId}/images/{fileName} |
거부 | 조회·목록·검증된 업로드·수정·삭제 | 거부 |
겹치는 허용 규칙은 OR로 평가됩니다
하나의 요청이 여러 match에 걸리면, 일치한 규칙 중 하나의 allow라도 참인 순간 접근이 허용됩니다. 좁은 경로에 if false를 추가해도 더 넓은 경로의 if true를 취소할 수 없습니다. 특히 재귀 와일드카드에 넓은 읽기·쓰기를 허용하고 아래에서 제한하려는 구조를 피해야 합니다. 자세한 평가 방식은 Firestore 규칙 구조에 설명되어 있습니다.
하위 컬렉션은 별도 경로로 설계합니다
posts/{postId}에 작성한 규칙은 posts/{postId}/comments/{commentId}에 자동으로 적용되지 않습니다. 댓글을 도입한다면 해당 하위 컬렉션의 읽기·생성·수정·삭제 조건을 별도 match에 작성하고 테스트해야 합니다. 아래 예제에는 댓글 규칙이 없으므로 댓글 경로는 기본적으로 거부됩니다.
Firestore: 소유권과 필드 무결성 검사하기
소유권 규칙은 단순히 요청에 들어온 UID를 믿어서는 안 됩니다. 생성 시에는 새 문서의 소유자가 인증 UID인지 확인하고, 수정·삭제 시에는 기존 문서의 소유자를 확인해야 합니다. 수정에서는 저장 이후 문서가 기존 소유자 값을 유지하는지와 변경 가능한 필드 범위까지 검사합니다.
resource.data와 request.resource.data의 역할
resource.data: 요청 전에 데이터베이스에 저장되어 있던 문서입니다. 기존 소유권 확인에 사용합니다.request.resource.data: 쓰기가 성공했을 때 저장될 완성 문서입니다. 생성 값, 수정 후 소유권, 허용 필드와 타입을 검사합니다.- 삭제에는 저장될 새 문서가 없으므로 삭제 조건에서
request.resource.data에 의존하지 않습니다.
이 구분은 Firestore 규칙 조건 작성의 데이터 접근 모델을 따릅니다.
소유권·필드·작업을 결합한 Firestore 예제
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function signedIn() {
return request.auth != null;
}
function ownsExisting() {
return signedIn()
&& resource.data.ownerId == request.auth.uid;
}
function validProfile() {
return request.resource.data.keys()
.hasAll(['ownerId', 'displayName'])
&& request.resource.data.keys()
.hasOnly([
'ownerId', 'displayName', 'photoURL',
'createdAt', 'updatedAt'
])
&& request.resource.data.ownerId is string
&& request.resource.data.displayName is string
&& request.resource.data.displayName.size() > 0
&& request.resource.data.displayName.size() <= 40;
}
match /profiles/{userId} {
allow get: if signedIn()
&& request.auth.uid == userId;
allow list: if false;
allow create: if signedIn()
&& request.auth.uid == userId
&& request.resource.data.ownerId == request.auth.uid
&& validProfile();
allow update: if signedIn()
&& request.auth.uid == userId
&& ownsExisting()
&& request.resource.data.ownerId == resource.data.ownerId
&& request.resource.data.diff(resource.data)
.affectedKeys()
.hasOnly(['displayName', 'photoURL', 'updatedAt'])
&& validProfile();
allow delete: if signedIn()
&& request.auth.uid == userId
&& ownsExisting();
}
function validPost() {
return request.resource.data.keys()
.hasAll(['ownerId', 'title', 'published'])
&& request.resource.data.keys()
.hasOnly([
'ownerId', 'title', 'body', 'published',
'createdAt', 'updatedAt'
])
&& request.resource.data.ownerId is string
&& request.resource.data.title is string
&& request.resource.data.published is bool;
}
function canReadPost() {
return resource.data.published == true
|| ownsExisting();
}
match /posts/{postId} {
allow get: if canReadPost();
allow list: if request.query.limit <= 20
&& canReadPost();
allow create: if signedIn()
&& request.resource.data.ownerId == request.auth.uid
&& validPost();
allow update: if ownsExisting()
&& request.resource.data.ownerId == resource.data.ownerId
&& request.resource.data.diff(resource.data)
.affectedKeys()
.hasOnly(['title', 'body', 'published', 'updatedAt'])
&& validPost();
allow delete: if ownsExisting();
}
}
}
허용 목록 방식으로 미래 필드까지 보호합니다
keys().hasOnly()는 문서에 저장할 수 있는 필드 전체를 제한하고, diff().affectedKeys().hasOnly()는 수정 요청에서 실제로 바꿀 수 있는 필드만 제한합니다. 금지 필드 몇 개를 나열하는 방식보다 허용 필드를 명시하면 새 민감 필드가 추가됐을 때 클라이언트가 자동으로 수정 권한을 얻지 않습니다. 필드 제한 문법은 Firestore 특정 필드 접근 제어에서 확인할 수 있습니다.
예제 범위: 위 함수는 소유권과 대표 필드만 보여주는 시작점입니다. photoURL, body, 시간 필드처럼 허용 목록에 넣은 선택 필드도 운영 스키마에 맞춰 타입·길이·형식·변경 시점을 추가 검증해야 합니다. 필드 이름만 허용했다고 값의 무결성까지 보장되는 것은 아닙니다.
쿼리와 규칙: 같은 조건을 증명하기
Firestore Security Rules는 쿼리 결과를 받아 허용된 문서만 남기는 필터가 아닙니다. Firestore는 쿼리가 반환할 수 있는 잠재적 결과 전체가 규칙 조건을 만족하는지 평가합니다. 현재 데이터가 우연히 모두 공개 상태여도, 비공개 문서를 반환할 가능성이 있는 무제약 컬렉션 쿼리는 거부될 수 있습니다.
규칙 조건을 쿼리 제약으로 반복합니다
앞의 규칙은 목록 요청에 최대 20개 제한과 공개 글 또는 자기 글이라는 조건을 요구합니다. 따라서 공개 목록은 published == true, 내 글 목록은 ownerId == 현재 UID를 쿼리에 포함하고 두 쿼리 모두 limit(20) 이하를 사용합니다.
import {
collection,
limit,
query,
where,
} from 'firebase/firestore';
const publicPostsQuery = query(
collection(db, 'posts'),
where('published', '==', true),
limit(20),
);
const myPostsQuery = query(
collection(db, 'posts'),
where('ownerId', '==', auth.currentUser.uid),
limit(20),
);
// 공개·소유 조건이 없고 limit도 없으므로 앞의 list 규칙과 맞지 않습니다.
const deniedQuery = query(collection(db, 'posts'));
단건 조회와 목록 조회를 따로 검증합니다
get은 특정 문서 하나의 실제 내용을 규칙으로 확인하지만, list는 쿼리의 잠재 결과를 검사합니다. 같은 읽기 기능처럼 보여도 실패 조건이 다르므로 테스트도 분리해야 합니다. 쿼리 제약과 규칙의 관계는 Firestore 쿼리 보안 문서를 기준으로 확인합니다.
Storage: 사용자 경로와 파일 조건 결합하기

Cloud Storage는 파일 이름만 검사하기보다 UID가 포함된 경로를 권한 경계로 사용하면 소유권을 명확히 표현할 수 있습니다. 예를 들어 users/{userId}/images/{fileName}에서 경로의 userId와 인증 UID가 같은지 확인합니다. 업로드와 교체에는 새 객체의 크기와 contentType도 함께 검사합니다.
업로드·교체·삭제 조건을 분리합니다
업로드와 교체에는 request.resource가 있으므로 크기와 콘텐츠 유형을 검사할 수 있습니다. 삭제에는 새 객체가 없으므로 같은 검사 함수를 호출하지 않고 경로 소유권만 확인합니다. get과 list도 제품 요구에 따라 분리할 수 있습니다.
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
function isOwner(userId) {
return request.auth != null
&& request.auth.uid == userId;
}
function validImage() {
return request.resource.size <= 5 * 1024 * 1024
&& request.resource.contentType
.matches('image/(jpeg|png|webp)');
}
match /users/{userId}/images/{fileName} {
allow get, list: if isOwner(userId);
allow create: if isOwner(userId) && validImage();
allow update: if isOwner(userId) && validImage();
allow delete: if isOwner(userId);
}
}
}
경로 권한과 파일 검증은 서로 대체하지 않습니다
자기 경로라는 사실만 검사하면 허용하지 않은 크기나 유형의 객체가 저장될 수 있고, 파일 조건만 검사하면 다른 사용자의 경로에 쓸 수 있습니다. 두 조건을 AND로 결합해야 합니다. Storage의 인증·경로·메타데이터 조건은 Cloud Storage 보안 개요와 Storage 규칙 조건, 작업 세분화는 Storage 규칙 핵심 구문에서 확인할 수 있습니다.
contentType 한계: Storage 규칙의 request.resource.contentType은 요청 메타데이터를 검사하는 값이며 파일 바이트를 분석해 실제 형식을 증명하지 않습니다. 악성 파일 탐지나 실제 이미지 디코딩 검증이 보안 요구사항이라면 업로드 후 신뢰 서버에서 검사하고, 검증 전 객체는 공개·처리 경로에서 분리해야 합니다.
Emulator Suite: 성공과 실패를 회귀 테스트하기
콘솔의 규칙 시뮬레이터로 한두 요청을 확인하는 것만으로는 경로와 역할의 조합을 계속 보장하기 어렵습니다. 규칙 파일을 저장소의 코드와 함께 관리하고, Local Emulator Suite에서 단위 테스트를 실행하면 운영 데이터에 접근하지 않고 변경의 영향을 반복 검증할 수 있습니다.
규칙 파일과 에뮬레이터를 한 설정에 연결합니다
{
"firestore": {
"rules": "firestore.rules"
},
"storage": {
"rules": "storage.rules"
},
"emulators": {
"firestore": { "port": 8080 },
"storage": { "port": 9199 },
"ui": { "enabled": true }
}
}
firebase init emulators
firebase emulators:start --only firestore,storage
# CI에서는 에뮬레이터 시작·테스트·종료를 한 명령으로 실행합니다.
firebase emulators:exec --only firestore,storage "npm test"
설치와 명령 옵션은 Local Emulator Suite 설치 및 구성을 기준으로 합니다.
허용 사례보다 거부 사례를 더 구체적으로 씁니다
@firebase/rules-unit-testing은 인증된 사용자와 비로그인 사용자의 테스트 컨텍스트를 만들고 assertSucceeds, assertFails로 결과를 단언합니다. 다음 예제는 Firestore의 소유자 조회와 소유권 변조, Storage의 정상 업로드·타인 경로·크기 초과를 함께 검사합니다.
import { readFileSync } from 'node:fs';
import {
assertFails,
assertSucceeds,
initializeTestEnvironment,
} from '@firebase/rules-unit-testing';
import {
doc,
getDoc,
setDoc,
updateDoc,
} from 'firebase/firestore';
import { ref, uploadBytes } from 'firebase/storage';
const testEnv = await initializeTestEnvironment({
projectId: 'demo-security-rules',
firestore: {
rules: readFileSync('firestore.rules', 'utf8'),
},
storage: {
rules: readFileSync('storage.rules', 'utf8'),
},
});
try {
await testEnv.clearFirestore();
await testEnv.clearStorage();
await testEnv.withSecurityRulesDisabled(async (context) => {
await setDoc(doc(context.firestore(), 'profiles/alice'), {
ownerId: 'alice',
displayName: 'Alice',
createdAt: new Date(),
updatedAt: new Date(),
});
});
const alice = testEnv.authenticatedContext('alice');
const bob = testEnv.authenticatedContext('bob');
const guest = testEnv.unauthenticatedContext();
await assertSucceeds(
getDoc(doc(alice.firestore(), 'profiles/alice')),
);
await assertFails(
getDoc(doc(bob.firestore(), 'profiles/alice')),
);
await assertFails(
getDoc(doc(guest.firestore(), 'profiles/alice')),
);
await assertFails(
updateDoc(doc(alice.firestore(), 'profiles/alice'), {
ownerId: 'bob',
}),
);
const image = new Uint8Array(1024);
await assertSucceeds(
uploadBytes(
ref(alice.storage(), 'users/alice/images/avatar.png'),
image,
{ contentType: 'image/png' },
),
);
await assertFails(
uploadBytes(
ref(bob.storage(), 'users/alice/images/avatar.png'),
image,
{ contentType: 'image/png' },
),
);
await assertFails(
uploadBytes(
ref(alice.storage(), 'users/alice/images/large.png'),
new Uint8Array(5 * 1024 * 1024 + 1),
{ contentType: 'image/png' },
),
);
} finally {
await testEnv.cleanup();
}
역할·작업·경계값 테스트 표를 유지합니다
| 대상 | 반드시 성공할 사례 | 반드시 실패할 사례 |
|---|---|---|
| 프로필 | 소유자의 단건 조회와 허용 필드 수정 | 비로그인·비소유자 조회, ownerId 변경, 목록 조회 |
| 게시물 | 공개 조건·제한을 포함한 목록, 소유자의 비공개 글 조회 | 조건 없는 목록, 20개 초과 요청, 타인 글 수정·삭제 |
| 이미지 | 자기 경로의 5MiB 이하 허용 유형 업로드 | 타인 경로, 크기 초과, 허용하지 않은 콘텐츠 유형 |
테스트 환경과 단언 API는 Security Rules 단위 테스트, Firestore 데이터 초기화와 평가 추적은 Firestore Emulator 규칙 테스트를 참고합니다.
배포 전후 체크리스트
배포 전
- 실제 경로와 하위 컬렉션을 빠짐없이 목록화했는지 확인합니다.
- 각 경로의
get,list,create,update,delete권한을 역할별 표와 비교합니다. - 겹치는 상위·재귀
match에 넓은allow가 남아 있지 않은지 검색합니다. - 모든 목록 쿼리가 규칙이 요구하는
where와limit을 포함하는지 확인합니다. - 소유권 변조, 새 필드 주입, 타인 경로, 경계값 초과 같은 실패 테스트가 통과하는지 확인합니다.
firebase.json이 검증한 로컬 규칙 파일을 가리키는지 확인합니다.
규칙만 선택해 배포
firebase deploy --only firestore:rules
firebase deploy --only storage
Firebase CLI로 규칙을 배포하면 로컬 파일이 콘솔의 규칙을 덮어쓸 수 있으므로, 콘솔에서만 수정한 내용을 기준으로 배포하지 않습니다. 검증된 파일을 버전 관리하고 테스트한 동일 파일을 배포하는 절차를 유지합니다. 공식 절차는 Firebase Security Rules 관리 및 배포에서 확인할 수 있습니다.
배포 후
- 비로그인, 정상 사용자, 다른 사용자 계정으로 핵심 읽기·쓰기 흐름을 짧게 점검합니다.
- 공개 목록과 내 목록이 각각 의도한 쿼리 조건으로 동작하는지 확인합니다.
- 예상하지 못한
permission-denied가 발생하면 규칙을 즉시 넓히지 말고 경로·작업·인증 UID·쿼리 조건을 먼저 기록합니다. - 문제가 확인되면 버전 관리된 이전 규칙으로 되돌려 같은 테스트를 통과시킨 뒤 다시 배포합니다.
자주 생기는 설계 실수
| 실수 | 왜 위험하거나 실패하는가 | 수정 방향 |
|---|---|---|
request.auth != null만 확인 |
모든 로그인 사용자가 같은 권한을 얻습니다. | 문서 소유자 또는 경로 UID와 인증 UID를 비교합니다. |
| 상위 경로에서 넓게 허용한 뒤 하위에서 거부 | 겹치는 규칙 중 하나라도 참이면 허용되므로 하위 거부가 덮어쓰지 못합니다. | 넓은 허용을 제거하고 필요한 경로마다 좁게 허용합니다. |
| 공개 문서가 있으니 전체 컬렉션 조회 | 규칙은 결과 필터가 아니므로 비공개 문서 가능성이 있는 쿼리를 거부합니다. | 공개 상태 또는 소유자 조건과 제한을 쿼리에 포함합니다. |
| 수정에서 기존 소유권만 확인 | 요청자가 소유자 필드나 새 민감 필드를 바꿀 수 있습니다. | 수정 후 소유자 동일성과 변경 허용 필드를 함께 검사합니다. |
| Storage 확장자만 신뢰 | 규칙의 크기·콘텐츠 유형 조건이 없어 업로드 범위가 넓어집니다. | UID 경로, request.resource.size, contentType을 함께 검사합니다. |
| 허용 테스트만 작성 | 권한이 과도하게 열린 회귀를 발견하기 어렵습니다. | 비로그인·비소유자·변조·경계값 실패를 명시적으로 단언합니다. |
Firebase 공식 문서와 관련 글
확인 기준일: 2026-07-19. 기술 동작 설명은 아래 Firebase 공식 문서만 근거로 사용했습니다.
Firestore Security Rules
Cloud Storage Security Rules
테스트와 배포
함께 보면 좋은 내부 글
- Firebase permission-denied 오류 해결: Firestore Rules 체크리스트
- Firebase Storage 이미지 오류 해결: 403·404·token·Rules 확인법
- Firebase Auth 사용자 컨텍스트 구조 설계
결론: 규칙을 한 번의 설정이 아니라 운영 절차로 만드세요
안전한 Firebase 권한 관리는 긴 규칙 파일에서 나오지 않습니다. 첫째, 데이터 경로와 역할을 표로 만들고 기본 거부에서 시작합니다. 둘째, 로그인 여부에 소유권을 더하고 작업을 get, list, create, update, delete로 나눕니다. 셋째, Firestore의 기존 값과 쓰기 이후 값, Storage의 경로와 파일 조건을 함께 검사합니다. 넷째, 목록 쿼리가 규칙 조건을 직접 증명하도록 제약을 맞춥니다. 다섯째, 허용보다 거부 사례를 더 촘촘히 자동 테스트한 동일 규칙 파일만 배포합니다.
지금 적용할 다음 단계는 간단합니다. 현재 프로젝트의 경로·역할·작업 표를 먼저 작성하고, 가장 민감한 개인 경로 하나를 위 패턴으로 좁힌 뒤, 소유자 성공 1개와 비소유자 실패 1개를 Emulator Suite 테스트로 고정하세요. 그 작은 기준을 통과한 다음 다른 컬렉션과 Storage 경로로 확장하면 기능을 유지하면서도 권한 범위를 검증 가능한 방식으로 줄일 수 있습니다.
“Firebase 보안 규칙 설계: Firestore와 Storage 권한 관리하기”에 대한 9개의 생각