Securing an Angular Firebase AI App with Email/Password Authentication (Part 1: Emulator, AuthService, and Route Guard) [GDE]
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 suppor

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.
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:
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 } } } Enter fullscreen mode Exit fullscreen mode 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 Enter fullscreen mode Exit fullscreen mode 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 \" " } } Enter fullscreen mode Exit fullscreen mode Here is what each script does:
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.
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; } Enter fullscreen mode Exit fullscreen mode 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; Enter fullscreen mode Exit fullscreen mode Next, AuthService manages the Firebase Auth lifecycle through four core methods:
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; } Enter fullscreen mode Exit fullscreen mode ensureAuth() Ensures loadAuth() resolves exactly once and returns both the auth instance and the #authSdk module. Keeping both in memory allows signIn() and signOut() to reuse them when logging a user in or out. 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 }; } Enter fullscreen mode Exit fullscreen mode signIn() Accepts the user's email and password, validates the arguments, and calls sdk.signInWithEmailAndPassword() to authenticate the user. When authentication succeeds, the authSdk.onAuthStateChanged(auth, (user) => this.#user.set(user)) listener observes the state change, updates the #user signal, and recalculates the isAuthenticated computed signal. 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 ); } Enter fullscreen mode Exit fullscreen mode signOut() Calls sdk.signOut(auth) to sign out the currently logged-in user.
Similarly, the onAuthStateChanged listener resets the #user signal to null and recalculates the isAuthenticated computed signal. async signOut (): Promise < void > { const { auth, sdk } = await this. ensureAuth (); await sdk. signOut ( auth ); } } Enter fullscreen mode Exit fullscreen mode 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 ]); }; Enter fullscreen mode Exit fullscreen mode 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 '; Enter fullscreen mode Exit fullscreen mode 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; Enter fullscreen mode Exit fullscreen mode // 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 ], }, Enter fullscreen mode Exit fullscreen mode Takeaways & What's Next In Part 1, we configured Firebase Auth to protect our image analysis and TTS endpoints:
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.