Building a Serverless Backend with Firebase Firestore (and Security Rules That Actually Protect You)
A practical guide to building a serverless backend with Firebase: data modelling in Firestore, reading and writing from the web, and writing security rules that keep your data safe.
A lot of the projects I build are for organisations that need something reliable without a big budget or a dedicated server team: a non-profit tracking its impact, a small business managing stock, a training programme running online courses. For many of these, a serverless backend with Firebase is a great fit.
"Serverless" does not mean there are no servers. It means you don't manage them. You get authentication, a database, file storage and hosting as services, and you focus on your app.
But there is a catch that bites many beginners: with Firestore, your web app talks to the database directly from the browser. That means your security rules are not optional. They are your backend's security. In this guide I will cover both: building the backend, and locking it down.
What we are building
Let's use a simple example: a course platform where:
- Anyone can read published courses
- Signed-in learners can enrol and see their own progress
- Only admins can create or edit courses
Step 1: Model your data around how you read it
Firestore is a document database. Data lives in documents (like JSON objects) inside collections. The most important mindset shift from SQL: design your structure around the queries your app makes, not just around "normalised" tables.
A reasonable structure:
courses/{courseId}
title, description, published, createdAt
users/{userId}
displayName, role
users/{userId}/enrolments/{courseId}
enrolledAt, progressPutting enrolments under each user makes "show my courses" a simple, cheap query, and it makes security rules easy to write: a user can only touch documents under their own ID.
Step 2: Read and write from the web
Using the modular Firebase JavaScript SDK, initialise your app and get a Firestore instance:
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
import { getAuth } from "firebase/auth";
const app = initializeApp({
apiKey: "...",
authDomain: "...",
projectId: "..."
});
export const db = getFirestore(app);
export const auth = getAuth(app);A quick note: the Firebase web config, including apiKey, is meant to be public. It identifies your project; it does not grant access. What protects your data is security rules (and features like App Check). This is exactly why rules matter so much.
Reading published courses:
import { collection, query, where, orderBy, getDocs } from "firebase/firestore";
export async function getPublishedCourses() {
const q = query(
collection(db, "courses"),
where("published", "==", true),
orderBy("createdAt", "desc")
);
const snap = await getDocs(q);
return snap.docs.map(d => ({ id: d.id, ...d.data() }));
}Enrolling the current user:
import { doc, setDoc, serverTimestamp } from "firebase/firestore";
export async function enrol(courseId) {
const user = auth.currentUser;
if (!user) throw new Error("Please sign in first.");
await setDoc(doc(db, "users", user.uid, "enrolments", courseId), {
enrolledAt: serverTimestamp(),
progress: 0
});
}Firestore may ask you to create a composite index for queries that combine where and orderBy on different fields. The error message includes a link to create it — just follow it.
Step 3: Security rules, the part you must not skip
When you create a Firestore database, you will see options like "test mode", which allows open access for a limited time. That is fine for a first experiment, but never launch with open rules. Anyone who finds your project ID could read or delete everything.
Rules live in a firestore.rules file. They match document paths and say who can read or write. Here is a set for our course platform:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function signedIn() {
return request.auth != null;
}
function isOwner(userId) {
return signedIn() && request.auth.uid == userId;
}
function isAdmin() {
return signedIn() && request.auth.token.admin == true;
}
match /courses/{courseId} {
allow read: if resource.data.published == true || isAdmin();
allow create, update, delete: if isAdmin();
}
match /users/{userId} {
allow read: if isOwner(userId) || isAdmin();
allow create: if isOwner(userId)
&& !("role" in request.resource.data);
allow update: if isOwner(userId)
&& !request.resource.data.diff(resource.data).affectedKeys().hasAny(["role"]);
match /enrolments/{courseId} {
allow read: if isOwner(userId) || isAdmin();
allow create: if isOwner(userId)
&& request.resource.data.progress == 0;
allow update: if isOwner(userId)
&& request.resource.data.progress is number
&& request.resource.data.progress >= 0
&& request.resource.data.progress <= 100;
allow delete: if isOwner(userId);
}
}
}
}Let's unpack the key ideas.
Deny by default
If no rule allows an operation, it is denied. Start from nothing and open only what you need.
Rules are not filters
This one confuses many developers. If a query could return a document the user isn't allowed to read, Firestore rejects the whole query. That is why our getPublishedCourses query includes where("published", "==", true) — it matches the rule, so Firestore can prove the query is safe.
Validate the data, not just the user
Checking that a user is signed in is not enough. In the rules above, users can update their own profile but cannot change their own role. Without that check, any user could promote themselves to admin. Similarly, we check that progress is a number between 0 and 100.
Use custom claims for admins
Notice request.auth.token.admin. That is a custom claim set on the user's auth token from a trusted server environment (for example, using the Firebase Admin SDK in a Cloud Function or a secure script). Users cannot set their own claims, which makes this safer than storing role: "admin" in a document that a buggy rule might let them edit.
Step 4: Test your rules
Don't just hope your rules work. Test them.
- Use the Rules Playground in the Firebase console to simulate reads and writes as different users.
- Use the Firebase Local Emulator Suite to run Firestore and Auth locally, and write automated tests that try forbidden actions and expect them to fail.
A good habit: for every rule, write at least one test that should succeed and one that should fail.
Step 5: When you need real server logic
Some things must not happen in the browser: sending emails, processing payments, calculating trusted totals, or setting custom claims. For these, use Cloud Functions (or another trusted backend). The browser asks; the server decides.
Security checklist before launch
- Test mode rules are gone; nothing is open to everyone by accident
- Every collection has explicit read and write rules
- Users can only access their own private data
- Users cannot change fields like
role,isAdminor prices - Incoming data is validated: types, ranges, required fields
- Admin access uses custom claims set from a trusted environment
- Rules are tested, including tests that should fail
- Sensitive logic runs in Cloud Functions, not the browser
- Budget alerts are set up in your cloud billing account
Conclusion
Firebase lets a small team — or a single developer — ship a real product quickly. I have used this approach for dashboards and learning platforms, and it works well. But its speed comes with responsibility: in a serverless app, your security rules are your backend. Treat them like code: design them, review them and test them.
Are you using Firestore in a project right now? Paste a (non-sensitive) rule you are unsure about in the comments, and let's improve it together.

0 Comments