Anyone with the public URL can use my tokens to perform image analysis and text-to-speech, which can quickly drive up my billing costs. To prevent unauthorized usage, I want to convert /dashboard into a protected route by adding Email/Password authentication with Firebase Auth.
Firebase Auth supports many authentication providers out of the box, and I chose Email/Password because it is the simplest method to configure. In Part 1, we will configure the Firebase Auth Local Emulator, build a lazy-loaded AuthService and a functional route guard, and protect the /dashboard route using CanActivateFn.
Architecture & Mental Model
Part 1 focuses on running the Firebase Auth Emulator alongside the Angular dev server and configuring authentication state management.
The architecture uses Firebase's browser session persistence (browserSessionPersistence) to manage the user's authentication state, which provides two key benefits:
- After a user authenticates successfully, Firebase persists the authentication state in
sessionStorage. When the user refreshes the browser page, the browser retains their login state. - When the user closes the browser tab, the browser automatically clears the authentication state from
sessionStorage. The next time the user visits the application, they must log in again.
Without session persistence, the application would require extra custom logic to check token expiration, explicitly purge the user state from storage, and trigger route redirection.
Similarly, because firebase/auth is a heavy third-party SDK, loading it eagerly would increase the initial bundle size. Instead, AuthService lazy-loads it via a dynamic import() to update the reactive isAuthenticated signal.
flowchart LR subgraph Dev["Local Development (npm run launch)"] NG["Angular Dev Server (:4200)"] EMU["Firebase Auth Emulator (:9099 & UI :4000)"] end subgraph AuthCore["AuthService (src/app/core/auth)"] ENSURE["ensureAuth() (Single-Flight Promise)"] SDK["Dynamic import('firebase/auth')"] STATE["onAuthStateChanged -> #user Signal"] COMP["isAuthenticated Computed Signal"] ENSURE --> SDK --> STATE --> COMP end subgraph Routing["Angular Router (app.routes.ts)"] GUARD["canActivateDashboard (CanActivateFn)"] HOME["/home (Sign-In Screen)"] DASH["/dashboard (Image Analysis & TTS)"] end NG --> ENSURE SDK -. "isLocalhost()" .-> EMU GUARD --> ENSURE COMP -- "true" --> DASH COMP -- "false (Redirect)" --> HOME 1. Configuring the Firebase Auth Emulator & Unified Launch Script
1.1 Enabling the Auth Emulator in firebase.json
The { "emulators": { "auth": ... } } block in firebase/firebase.json configures the Firebase Auth Emulator to run on host port 9099 (127.0.0.1:9099), while "ui": { "enabled": true } enables the Emulator UI on port 4000 (http://127.0.0.1:4000).
// firebase/firebase.json { // ... rest of configuration omitted for brevity "emulators": { "auth": { "port": 9099 }, "ui": { "enabled": true } } } 1.2 Installing concurrently as a Dev Dependency
This step is optional, but I prefer not to start the Firebase Auth Emulator and the Angular dev server in two separate steps—opening one terminal tab to run the emulator and a second terminal tab to run the Angular dev server. Installing concurrently allows us to run both processes with a single command so we can start development immediately.
npm install --save-dev concurrently 1.3 Adding Emulator and Launch Scripts in package.json
During local development, I connect the Angular application to the Firebase Authentication Emulator to seed a test user for login. AuthService connects to the emulator programmatically, and the new scripts in package.json launch it.
// package.json (scripts excerpt) { "scripts": { "start": "ng serve", "firebase:emulate:auth": "firebase --config firebase/firebase.json emulators:start --only auth", "launch": "concurrently \"npm run firebase:emulate:auth\" \"npm run start\"" } } Here is what each script does:
-
start: Starts the local Angular development server (ng serve) onhttp://localhost:4200. -
firebase:emulate:auth: Reads thefirebase/firebase.jsonconfiguration to start the Authentication Emulator only. -
launch: Launches both the Angular client and the Firebase emulator concurrently, allowing me to prompt the Antigravity CLI to execute it in the background.
2. Creating a Test Account in the Firebase Emulator UI
Seeding a user is a manual process, so I open the Emulator UI at http://localhost:4000 to create a dummy user with the credentials aaa@example.com and 123456. Note that 123456 is a weak password created strictly to facilitate local authentication testing.

Caption: Creating a new test user with an email and password in the Firebase Emulator UI at http://localhost:4000.
Saving the form creates the user account. Because the users table displays the email address without the password, I recommend using a password that is easy to remember or saving it in a password manager.

Caption: The newly created email/password user account displayed in the Firebase Authentication Emulator.
3. Building a Lazy-Loaded, Signal-Driven AuthService
3.1 Defining the AuthCredentials Interface
The AuthCredentials interface defines the email and password properties for user login credentials.
// src/app/core/auth/auth-credentials.interface.ts export interface AuthCredentials { email: string; password: string; } 3.2 Implementing AuthService with Dynamic SDK Loading and Signals
First, AuthService injects WINDOW, ConfigService, and DestroyRef, and declares the #user signal, the isAuthenticated computed signal, and private fields to cache the Firebase Auth instance and SDK module:
// src/app/core/auth/auth.service.ts import { WINDOW } from '@/core/constants/navigator.const'; import { ConfigService, isLocalhost } from '@/core/firebase'; import { computed, DestroyRef, inject, Service, signal } from '@angular/core'; import type { Auth, User } from 'firebase/auth'; import type { AuthCredentials } from './auth-credentials.interface'; const AUTH_EMULATOR_URL = 'http://127.0.0.1:9099'; @Service() export class AuthService { readonly #window = inject(WINDOW); readonly #configService = inject(ConfigService); readonly #destroyRef$ = inject(DestroyRef); readonly #user = signal<User | null>(null); user = this.#user.asReadonly(); isAuthenticated = computed(() => !!this.#user()); #auth: Auth | null = null; #authSdk: typeof import('firebase/auth') | null = null; #authReady: Promise<void> | null = null; Next, AuthService manages the Firebase Auth lifecycle through four core methods:
-
loadAuth()- Dynamically imports
firebase/authfor the first time and retrieves the current Firebase application instance. - Connects the Angular application to the Firebase Auth Emulator when running on
localhost. - Sets authentication state persistence to browser session persistence (
browserSessionPersistence), registersonAuthStateChangedto monitor authentication state changes, and awaitsauth.authStateReady()for the initial auth state to resolve. - Stores the
authinstance in#authand the Auth SDK module in#authSdk.
- Dynamically imports
private async loadAuth(): Promise<void> { const [firebaseApp, authSdk] = await Promise.all([this.#configService.getApp(), import('firebase/auth')]); const auth = authSdk.getAuth(firebaseApp); if (isLocalhost(this.#window)) { authSdk.connectAuthEmulator(auth, AUTH_EMULATOR_URL, { disableWarnings: true }); } await authSdk.setPersistence(auth, authSdk.browserSessionPersistence); const unsubscribe = authSdk.onAuthStateChanged(auth, (user) => this.#user.set(user)); this.#destroyRef$.onDestroy(unsubscribe); await auth.authStateReady(); this.#auth = auth; this.#authSdk = authSdk; } -
ensureAuth()- Ensures
loadAuth()resolves exactly once and returns both theauthinstance and the#authSdkmodule. Keeping both in memory allowssignIn()andsignOut()to reuse them when logging a user in or out.
- Ensures
async ensureAuth(): Promise<{ auth: Auth; sdk: typeof import('firebase/auth') }> { if (!this.#authReady) { this.#authReady = this.loadAuth(); } await this.#authReady; if (!this.#auth) { throw new Error('Auth is not initialized'); } if (!this.#authSdk) { throw new Error('Auth SDK does not exist'); } return { auth: this.#auth, sdk: this.#authSdk }; } -
signIn()- Accepts the user's
emailandpassword, validates the arguments, and callssdk.signInWithEmailAndPassword()to authenticate the user. When authentication succeeds, theauthSdk.onAuthStateChanged(auth, (user) => this.#user.set(user))listener observes the state change, updates the#usersignal, and recalculates theisAuthenticatedcomputed signal.
- Accepts the user's
async signIn({ email, password }: AuthCredentials): Promise<void> { if (!email || !password) { this.#user.set(null); return; } const { auth, sdk } = await this.ensureAuth(); await sdk.signInWithEmailAndPassword(auth, email, password); } -
signOut()- Calls
sdk.signOut(auth)to sign out the currently logged-in user. Similarly, theonAuthStateChangedlistener resets the#usersignal tonulland recalculates theisAuthenticatedcomputed signal.
- Calls
async signOut(): Promise<void> { const { auth, sdk } = await this.ensureAuth(); await sdk.signOut(auth); } } 4. Protecting the Dashboard Route with canActivateDashboard
4.1 Creating the CanActivateFn Route Guard
canActivateDashboard is a functional route guard of type CanActivateFn. It injects AuthService and Router to verify the user's authentication status and handle route redirection. Calling await authService.ensureAuth() initializes Firebase Auth (connecting to the Auth Emulator when running on localhost) and updates the isAuthenticated computed signal with the current authentication state. When the user is authenticated, the guard returns true to allow navigation to the route; otherwise, it redirects the user back to /home via router.createUrlTree([APP_LINKS.HOME]).
// src/app/core/auth/auth.guard.ts import { AuthService } from './auth.service'; import { APP_LINKS } from '@/core/constants/routes.const'; import { inject } from '@angular/core'; import type { CanActivateFn } from '@angular/router'; import { Router } from '@angular/router'; export const canActivateDashboard: CanActivateFn = async () => { const authService = inject(AuthService); const router = inject(Router); await authService.ensureAuth(); if (authService.isAuthenticated()) { return true; } return router.createUrlTree([APP_LINKS.HOME]); }; Finally, we re-export the AuthCredentials interface, the canActivateDashboard route guard, and AuthService from a single barrel file (src/app/core/auth/index.ts). This gives the /dashboard route configuration—and the Sign-In component we will build in Part 2—a clean, unified import path.
// src/app/core/auth/index.ts export * from './auth-credentials.interface'; export * from './auth.guard'; export * from './auth.service'; 4.2 Wiring canActivateDashboard in app.routes.ts
The canActivate property of the dashboard route accepts the canActivateDashboard function in an array. When a user types /dashboard in the address bar or navigates to the path programmatically, the router executes this route guard function. Authenticated users proceed to the dashboard, while the guard blocks unauthenticated visitors and redirects them back to /home.
// src/app/core/constants/routes.const.ts export const ROUTE_PATHS = { HOME: 'home', DASHBOARD: 'dashboard', } as const; // src/app/app.routes.ts (dashboard route excerpt) { path: ROUTE_PATHS.DASHBOARD, title: 'Firebase TTS', providers: [provideEnvironmentInitializer(() => inject(ConfigService).initialize())], loadComponent: () => import('./features/dashboard/dashboard.component'), canActivate: [canActivateDashboard], }, Takeaways & What's Next
In Part 1, we configured Firebase Auth to protect our image analysis and TTS endpoints:
- Local Auth Emulator: Configured the Auth Emulator in
firebase.jsonand usedconcurrentlyto start the emulator and Angular app together. - Lazy-Loaded
AuthService: Dynamically importedfirebase/authonce and used signals (#userandisAuthenticated) to track the login state. - Route Guard: Added
canActivateDashboardtoapp.routes.tsto redirect unauthenticated users back to the home screen.
In Part 2, we will build the interactive sign-in experience on the frontend. We will use Angular 22's Signal Forms to create the form where users input their email and password, use Angular CDK Dialog to open the sign-in modal when a user clicks the Sign In button on the home screen, and connect the Sign-In component with AuthService to call the signIn() method.

![Why Object.keys(user) Returns string[], Not (keyof User)[]: TypeScript Means It](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2q4w48b9bzs43tynyhsf.png)