
Firebase Auth will sign a user in. Your rules and your API still have to decide what that user may read.
A sample that logs the UserCredential after signup is logging an ID token. A rule that is if true makes the token decorative. The integration is finished when the rule and the API agree.
The usual mistake is following a 2020 signup snippet and never opening Firestore rules or the token in the network panel.
This page is the Angular plus Firebase path that does not leak the token, and the rule that binds the uid to the document.
A 2020 Firebase signup sample logged the UserCredential after signup. That object holds the ID token. allow read, write: if request.auth != null on /{document=**} then lets every account that can sign up read every note. That is IDOR with a login screen.
Use a uid compare in rules, a create path that freezes ownerId, and a client that does not print tokens. Read the Firestore owner-check page for the full rules file. Read IDOR for the same bug in SQL. Keep the secure coding checklist next to both. A token you mint and then treat as a session is also a JWT problem if you stash it in localStorage.
A signed-in token is not a row bind
Firebase Authentication answers who called. Firestore Security Rules answer which row that caller may touch. Basic Security Rules. The page says clients talk to the data directly, and the rules file is the safeguard. There is no second check on a server you forgot to write.
Three pastes show up in real projects:
| Condition | Who gets in |
|---|---|
if false | Nobody. Locked mode. |
request.time < date | The internet, until the date. |
request.auth != null | Every signed-in uid. |
auth.uid == ownerId | The owner of that document. |
Test mode is world-open until a timestamp about 30 days out. Extending the date is not a policy. Delete the match. The development snippet that allows any authenticated user is documented as not recommended for production. It is still the line tutorials paste after createUserWithEmailAndPassword starts working.
Anonymous sign-in is a uid. Phone, Google, and email each mint a uid. A bot that can complete signup is a signed-in caller. Invite-only reduces who can mint a token. The token still has to match ownerId.
Wire Auth in 2026 without the 2020 module
AngularFire Auth notes on the main branch. The current shape is provideFirebaseApp and provideAuth on the application config. AngularFireModule.initializeApp and this.angularFireAuth.auth.createUserWithEmailAndPassword are the compatibility path. Do not copy them into a new app.
// app.config.ts
import { ApplicationConfig } from "@angular/core";
import { initializeApp, provideFirebaseApp } from "@angular/fire/app";
import { getAuth, provideAuth } from "@angular/fire/auth";
import { getFirestore, provideFirestore } from "@angular/fire/firestore";
import { environment } from "./environment";
export const appConfig: ApplicationConfig = {
providers: [
provideFirebaseApp(() => initializeApp(environment.firebaseConfig)),
provideAuth(() => getAuth()),
provideFirestore(() => getFirestore()),
],
};
environment.firebaseConfig holds apiKey, authDomain, projectId, and the rest. Those values ship to the browser. They are not a secret. Security is the rules file plus App Check, not hiding the config object. Put the object in environment files if you want. Do not treat the api key as a password.
The named Auth helper. Modular SDK. No .auth chain.
// auth.service.ts
import { inject, Injectable } from "@angular/core";
import {
Auth,
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signOut,
user,
} from "@angular/fire/auth";
@Injectable({ providedIn: "root" })
export class AuthService {
private auth = inject(Auth);
readonly user$ = user(this.auth);
signUp(email: string, password: string) {
return createUserWithEmailAndPassword(this.auth, email, password);
}
signIn(email: string, password: string) {
return signInWithEmailAndPassword(this.auth, email, password);
}
signOut() {
return signOut(this.auth);
}
}
AuthService is the only class that talks to Auth. Components call signUp, signIn, and signOut. They do not import the SDK. Email and password still travel on TLS because the page is HTTPS. Firebase Hosting and a custom domain with HTTPS are the transport. A tutorial that boots ng serve on HTTP and calls that production-ready is lying about the login POST.
Do not log the credential
The 2020 sample printed res after signup. UserCredential includes user. user includes tokens you can refresh. A screenshot, a support dump, or a log aggregator then holds a live session. Do not log it. Do not put it in localStorage yourself. The SDK already keeps the session. If you need the uid in the UI, read user.uid and stop.
// BAD: do not ship
// this.auth.signUp(email, password).then((res) => console.log(res));
// FIX: AuthService.signUp, then navigate. No credential in logs.
async onSignUp() {
await this.auth.signUp(this.email, this.password);
this.email = "";
this.password = "";
}
Password policy still belongs on the project. Firebase Auth password documentation. The console can require a minimum length and can block common passwords. That is how you stop a spray from minting weak accounts that then walk an open rules file. Cap attempts at your own edge if you terminate TLS there. Identity Platform rate limits exist on Google’s side. I am not treating them as a substitute for rules.
A route guard is UX, not the lock
A guard stops the router from rendering a component. Anyone can still call Firestore from a console, a modified APK, or a curl with a stolen token. The document is the object. The rule is the authorization.
// signed-in.guard.ts
import { inject } from "@angular/core";
import { Auth, authState } from "@angular/fire/auth";
import { CanActivateFn, Router } from "@angular/router";
import { map } from "rxjs";
export const signedInGuard: CanActivateFn = () => {
const auth = inject(Auth);
const router = inject(Router);
return authState(auth).pipe(
map((u) => {
if (u) return true;
return router.createUrlTree(["/login"]);
})
);
};
signedInGuard is a signed-in check. Mount it on /dashboard so a logged-out person sees login. Do not read it as “Alice cannot open Bob’s note.” Bob’s note is notes/note-bob. The guard never sees that path.
The 2020 list of five guard types is still in the router docs under different names. CanActivate became a function. CanLoad gave way to CanMatch. None of them run on the Firestore SDK. Put the owner compare in rules. Put the same compare in a Cloud Function if the Admin SDK is the caller, because Admin bypasses rules.
Bind uid on the document
same basics page at content-owner access. Two first-party patterns exist. Path owner: the document id is the uid. Field owner: the document id is a note id, and ownerId holds the uid.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function signedIn() {
return request.auth != null;
}
function ownsNote() {
return signedIn()
&& request.auth.uid == resource.data.ownerId;
}
function createOwnNote() {
return signedIn()
&& request.resource.data.ownerId == request.auth.uid;
}
match /notes/{noteId} {
allow get, list: if ownsNote();
allow create: if createOwnNote();
allow update: if ownsNote() && createOwnNote()
&& request.resource.data.ownerId == resource.data.ownerId;
allow delete: if ownsNote();
}
}
}
resource.data is the existing document. request.resource.data is the document after the write. The update line requires both, so Alice cannot PATCH ownerId to Bob and cannot take Bob’s note. Create is where IDOR hides. A rule that only checks a non-null auth object on create lets Alice write { ownerId: "bob-uid" }.
Client writes copy ownerId from user.uid for convenience. The rule is the enforcement. List queries must include the same constraint the rule can prove. A bare collection("notes").get() fails once ownsNote() is on list. Alice lists with where("ownerId", "==", aliceUid).
alice-uid GET /notes/note-bob signedIn() PASS uid == ownerId FAIL deny alice-uid GET /notes/note-alice uid == ownerId PASS allow null auth GET /notes/note-alice signedIn() FAIL deny
Custom claims are for roles, not for “this uid owns this row.” request.auth.token.admin == true is a second function next to ownsNote() for a support tool. It does not replace the owner compare on the user path.
App Check after the owner rule
App Check answers whether the caller looks like your app. It does not answer whether Alice owns the note. reCAPTCHA Enterprise App Check page. Register the web app, pass a score-based key, then enforce after you watch metrics. Default token TTL is one hour. Do not add localhost to a production reCAPTCHA key.
import { initializeApp } from "firebase/app";
import { initializeAppCheck, ReCaptchaEnterpriseProvider } from "firebase/app-check";
import { environment } from "./environment";
const app = initializeApp(environment.firebaseConfig);
initializeAppCheck(app, {
provider: new ReCaptchaEnterpriseProvider(environment.recaptchaKey),
isTokenAutoRefreshEnabled: true,
});
Enforcement without an owner rule still lets any installed copy of your app, with any signed-in uid, read every note. Ship the uid compare first. Turn on enforcement second. Debug builds need the debug provider. That is a local exception, not a production hole.
Prove Alice cannot read Bob
You are not attacking a production project. You are proving the rules file denies the second uid. Build unit tests. The library is @firebase/rules-unit-testing. If the emulator starts without a rules file, the docs say it treats the project as open. Point firebase.json at firestore.rules.
import {
assertFails,
assertSucceeds,
initializeTestEnvironment,
} from "@firebase/rules-unit-testing";
import { doc, getDoc, setDoc } from "firebase/firestore";
import { readFileSync } from "fs";
const PROJECT = "demo-notes";
const RULES = readFileSync("firestore.rules", "utf8");
let testEnv;
before(async () => {
testEnv = await initializeTestEnvironment({
projectId: PROJECT,
firestore: { rules: RULES, host: "127.0.0.1", port: 8080 },
});
});
it("alice is denied Bob", async () => {
const alice = testEnv.authenticatedContext("alice-uid").firestore();
await assertSucceeds(getDoc(doc(alice, "notes", "note-alice")));
await assertFails(getDoc(doc(alice, "notes", "note-bob")));
});
Seed note-alice and note-bob with withSecurityRulesDisabled in beforeEach. A green test that only uses alice-uid on note-alice is incomplete. The Bob get is the IDOR case. Also assert that Alice cannot setDoc a row whose ownerId is bob-uid.
Grep the client for the old names so a leftover tutorial copy does not come back:
rg -n "AngularFireModule|angularFireAuth\\.auth|console\\.log\\(.*[Rr]es|request\\.auth != null" \
--glob '!node_modules'
A hit on AngularFireModule is a migration. A hit on console.log next to a credential is a delete. A hit on request.auth != null without a uid compare is the open database.
Questions we keep getting
Is a CanActivate guard enough if signup is invite only?
No. Invite-only reduces who can mint a token. One invited user still walks notes/{noteId} unless ownerId matches. The guard only hides the component.
Do I hide note ids if the rule already checks ownerId?
You can. You still need the compare. Sjoerd’s line is the whole point: an unguessable id is not access control. Auto-ids stop casual scans. They do not stop a leaked link.
Can I keep AngularFireModule if the app already works?
It will run on the compatibility layer for a while. New code should use provideAuth and the modular functions. The security bug is not the module name. The security bug is a missing uid compare. Fix the rules first. Then delete the old import so the next tutorial paste has nowhere to land.



