
Locking It Down: A Complete Guide to Firebase Auth in Ionic + Angular

D. Rout
April 11, 2026 10 min read
On this page
Locking It Down: A Complete Guide to Firebase Auth in Ionic + Angular
Authentication is the gateway to every meaningful app experience. Get it wrong and you're either leaking user data or frustrating the people who just want to log in. In this tutorial we'll wire Firebase Authentication into an Ionic + Angular standalone application from scratch — covering Email/Password, Google Sign-In, Angular route guards, and Capacitor mobile packaging.
GitHub repo: The complete working project lives here. Clone it and follow along, or use it as a reference.
What we're building
By the end of this tutorial you will have:
- A
LoginPageandRegisterPagewith reactive forms and error toasts - A
HomePageprotected by a functionalauthGuard - An
AuthServicebacked by aBehaviorSubjectso the whole app reacts to auth state changes - Google Sign-In via Firebase popup
- A
guestGuardthat redirects already-logged-in users away from login/register - A clean standalone Angular setup (no
NgModuleboilerplate)
Prerequisites
- Node.js 20+
- Ionic CLI: `npm install -g @ionic/cli`
- A Firebase project with Authentication enabled
1. Bootstrap the Ionic app
ionic start ionic-firebase-auth-demo blank --type=angular --standalone
cd ionic-firebase-auth-demo
The --standalone flag tells the Ionic CLI to generate an Angular project using standalone components — no AppModule required.
2. Install AngularFire and Firebase
npm install firebase @angular/fire
AngularFire is the official Angular SDK for Firebase. It ships tree-shakeable, zone-free APIs that map cleanly onto Angular's dependency injection.
3. Enable Authentication in Firebase Console
- Open console.firebase.google.com
- Navigate to Authentication → Sign-in method
- Enable Email/Password
- Enable Google (set a support email when prompted)
- Go to Project Settings → Your apps and copy the config object
4. Configure the environment
Add your Firebase config to src/environments/environment.ts:
export const environment = {
production: false,
firebase: {
apiKey: 'YOUR_API_KEY',
authDomain: 'YOUR_PROJECT_ID.firebaseapp.com',
projectId: 'YOUR_PROJECT_ID',
storageBucket: 'YOUR_PROJECT_ID.appspot.com',
messagingSenderId: 'YOUR_MESSAGING_SENDER_ID',
appId: 'YOUR_APP_ID',
},
};
Never commit real API keys to a public repo. Use environment variable substitution in your CI pipeline and add
src/environments/environment.tsto.gitignorefor projects that will be open-sourced.
5. Bootstrap Firebase in app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideIonicAngular } from '@ionic/angular/standalone';
import { initializeApp, provideFirebaseApp } from '@angular/fire/app';
import { getAuth, provideAuth } from '@angular/fire/auth';
import { environment } from '../environments/environment';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideIonicAngular(),
provideFirebaseApp(() => initializeApp(environment.firebase)),
provideAuth(() => getAuth()),
],
};
provideFirebaseApp and provideAuth register Firebase services with Angular's injector so you can inject(Auth) anywhere without importing a module.
6. Build the AuthService
The service owns all Firebase interactions and exposes a BehaviorSubject so any component can reactively subscribe to the current user.
// src/app/core/services/auth.service.ts
import { Injectable, inject } from '@angular/core';
import {
Auth,
GoogleAuthProvider,
User,
createUserWithEmailAndPassword,
onAuthStateChanged,
signInWithEmailAndPassword,
signInWithPopup,
signOut,
updateProfile,
} from '@angular/fire/auth';
import { BehaviorSubject, Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class AuthService {
private auth = inject(Auth);
private currentUserSubject = new BehaviorSubject<User | null>(null);
/** Observable stream of the current user (null when signed out). */
currentUser$: Observable<User | null> = this.currentUserSubject.asObservable();
constructor() {
// Firebase persists auth state across page reloads automatically.
// onAuthStateChanged fires on every state change — sign-in, sign-out, token refresh.
onAuthStateChanged(this.auth, (user) => {
this.currentUserSubject.next(user);
});
}
/** Returns the currently cached user synchronously. */
get currentUser(): User | null {
return this.currentUserSubject.value;
}
async register(email: string, password: string, displayName: string): Promise<void> {
const credential = await createUserWithEmailAndPassword(this.auth, email, password);
await updateProfile(credential.user, { displayName });
this.currentUserSubject.next(credential.user);
}
async login(email: string, password: string): Promise<void> {
await signInWithEmailAndPassword(this.auth, email, password);
}
async loginWithGoogle(): Promise<void> {
const provider = new GoogleAuthProvider();
await signInWithPopup(this.auth, provider);
}
async logout(): Promise<void> {
await signOut(this.auth);
}
}
A few things worth noting:
onAuthStateChangedis the canonical way to listen to auth state. It fires on page reload too, so you never miss a persisted session.BehaviorSubjectstores the last-emitted value, meaning late subscribers immediately get the current user instead of waiting for the next event.updateProfilesets thedisplayNameon the Firebase user object immediately after registration.
If you want to learn more about RxJS here is the link to my post RxJS: Mastering Reactive Programming in JavaScript ⚡
7. Set up the routes
// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';
import { guestGuard } from './core/guards/guest.guard';
export const routes: Routes = [
{ path: '', redirectTo: 'login', pathMatch: 'full' },
{
path: 'login',
loadComponent: () =>
import('./pages/login/login.page').then((m) => m.LoginPage),
canActivate: [guestGuard],
},
{
path: 'register',
loadComponent: () =>
import('./pages/register/register.page').then((m) => m.RegisterPage),
canActivate: [guestGuard],
},
{
path: 'home',
loadComponent: () =>
import('./pages/home/home.page').then((m) => m.HomePage),
canActivate: [authGuard],
},
];
loadComponent gives us lazy-loading for free with standalone components.
8. Write the route guards
AuthGuard — protect private pages
// src/app/core/guards/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.currentUser) {
return true;
}
return router.createUrlTree(['/login']);
};
GuestGuard — redirect authenticated users
// src/app/core/guards/guest.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const guestGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (!auth.currentUser) {
return true;
}
return router.createUrlTree(['/home']);
};
These are functional guards (Angular 15+) — plain functions, no class required. inject() works inside them because Angular calls them within an injection context.
Caveat:
auth.currentUserreads from theBehaviorSubjectsynchronously. On a cold page load, Firebase hasn't resolved the persisted session yet when the guard runs. For production apps, add a resolver that waits forcurrentUser$to emit its first non-pending value. See the Firebase docs on auth state for details.
9. Build the LoginPage
login.page.ts
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import {
IonButton, IonContent, IonInput, IonItem,
IonLabel, IonNote, IonSpinner, IonText,
ToastController,
} from '@ionic/angular/standalone';
import { AuthService } from '../../core/services/auth.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [
ReactiveFormsModule,
IonContent, IonItem, IonLabel, IonInput,
IonButton, IonText, IonNote, IonSpinner,
],
templateUrl: './login.page.html',
})
export class LoginPage {
private fb = inject(FormBuilder);
private authService = inject(AuthService);
private router = inject(Router);
private toastCtrl = inject(ToastController);
loading = false;
form = this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(6)]],
});
async onSubmit(): Promise<void> {
if (this.form.invalid) return;
this.loading = true;
try {
const { email, password } = this.form.getRawValue();
await this.authService.login(email, password);
this.router.navigateByUrl('/home');
} catch (err: any) {
this.showToast(err.message ?? 'Login failed');
} finally {
this.loading = false;
}
}
async onGoogleLogin(): Promise<void> {
this.loading = true;
try {
await this.authService.loginWithGoogle();
this.router.navigateByUrl('/home');
} catch (err: any) {
this.showToast(err.message ?? 'Google sign-in failed');
} finally {
this.loading = false;
}
}
private async showToast(message: string): Promise<void> {
const toast = await this.toastCtrl.create({ message, duration: 3000, color: 'danger' });
toast.present();
}
}
fb.nonNullable.group removes the null union from control types, so you get clean string values out of getRawValue() without extra null checks.
login.page.html
<ion-content class="ion-padding">
<div class="auth-container">
<h1>Welcome back</h1>
<p>Sign in to continue</p>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<ion-item>
<ion-label position="floating">Email</ion-label>
<ion-input type="email" formControlName="email" />
</ion-item>
<ion-item>
<ion-label position="floating">Password</ion-label>
<ion-input type="password" formControlName="password" />
</ion-item>
<ion-button expand="block" type="submit" [disabled]="loading" class="ion-margin-top">
<ion-spinner *ngIf="loading" name="crescent" slot="start" />
Sign In
</ion-button>
</form>
<ion-button expand="block" fill="outline" (click)="onGoogleLogin()" [disabled]="loading">
Continue with Google
</ion-button>
<ion-text class="ion-text-center">
<p>Don't have an account? <a routerLink="/register">Register</a></p>
</ion-text>
</div>
</ion-content>
10. Build the RegisterPage
// register.page.ts (key excerpt)
form = this.fb.nonNullable.group({
displayName: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(6)]],
});
async onSubmit(): Promise<void> {
if (this.form.invalid) return;
this.loading = true;
try {
const { displayName, email, password } = this.form.getRawValue();
await this.authService.register(email, password, displayName);
this.router.navigateByUrl('/home');
} catch (err: any) {
// show error toast
} finally {
this.loading = false;
}
}
11. Build the HomePage
// home.page.ts
@Component({ ... })
export class HomePage {
authService = inject(AuthService);
private router = inject(Router);
async logout(): Promise<void> {
await this.authService.logout();
this.router.navigateByUrl('/login');
}
}
<!-- home.page.html -->
<ion-header>
<ion-toolbar>
<ion-title>Home</ion-title>
<ion-button slot="end" fill="clear" (click)="logout()">Sign out</ion-button>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="ion-text-center ion-padding-top">
<ion-avatar style="margin: 0 auto 16px">
<img
[src]="authService.currentUser?.photoURL ?? 'https://ionicframework.com/docs/img/demos/avatar.svg'"
alt="User avatar"
/>
</ion-avatar>
<h2>Hello, {{ authService.currentUser?.displayName ?? 'there' }}!</h2>
<p>{{ authService.currentUser?.email }}</p>
</div>
</ion-content>
12. Run it
ionic serve
Visit http://localhost:8100. The app redirects to /login, you can register a new account, sign in, and the home page shows the authenticated user's name and email.
13. Deploying to a real device with Capacitor
Capacitor works out of the box since the project was scaffolded with Ionic CLI.
ionic build
npx cap add ios # or android
npx cap sync
npx cap open ios # opens Xcode
For Google Sign-In on Android you'll need to add your app's SHA-1 fingerprint to Firebase. For iOS, configure a URL scheme in GoogleService-Info.plist. Full steps are in the AngularFire Capacitor guide.
Common Firebase Auth error codes
| Code | Meaning | User-facing fix |
|---|---|---|
auth/user-not-found |
No account with that email | "No account found. Try registering." |
auth/wrong-password |
Incorrect password | "Incorrect password." |
auth/email-already-in-use |
Email taken at registration | "That email is already registered." |
auth/too-many-requests |
Rate limited | "Too many attempts. Try again later." |
auth/popup-closed-by-user |
Google popup dismissed | Silent — user cancelled intentionally |
What's next
- Email verification — call
sendEmailVerification(user)after registration and checkuser.emailVerifiedin your guard - Password reset —
sendPasswordResetEmail(auth, email)sends the Firebase reset link - Custom claims — use Firebase Admin SDK to set role claims, then read them client-side with
user.getIdTokenResult() - Multi-factor authentication — available in Firebase's Identity Platform tier
Further reading
- Firebase Authentication Web Quickstart
- AngularFire Auth Documentation
- Ionic Angular Navigation & Routing
- Angular Route Guards (official docs)
- Capacitor Getting Started Guide
- RxJS BehaviorSubject API
Wrapping up
You now have a production-ready auth foundation in your Ionic + Angular app. The pattern — a service wrapping Firebase, a BehaviorSubject broadcasting state, functional guards protecting routes — scales cleanly as your app grows. Swap out the guard's synchronous check for an async resolver when you need bulletproof cold-start behaviour, and layer on email verification or custom claims when your use case demands it.
The full project is lives here — PRs welcome.
Read next
Comments (0)
Join the conversation
Sign in to leave a comment on this post.
No comments yet. to be the first!