Locking It Down: A Complete Guide to Firebase Auth in Ionic + Angular
ionic firebase angular authentication capacitor

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

D. Rout

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 LoginPage and RegisterPage with reactive forms and error toasts
  • A HomePage protected by a functional authGuard
  • An AuthService backed by a BehaviorSubject so the whole app reacts to auth state changes
  • Google Sign-In via Firebase popup
  • A guestGuard that redirects already-logged-in users away from login/register
  • A clean standalone Angular setup (no NgModule boilerplate)

Prerequisites


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

  1. Open console.firebase.google.com
  2. Navigate to Authentication → Sign-in method
  3. Enable Email/Password
  4. Enable Google (set a support email when prompted)
  5. 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.ts to .gitignore for 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:

  • onAuthStateChanged is the canonical way to listen to auth state. It fires on page reload too, so you never miss a persisted session.
  • BehaviorSubject stores the last-emitted value, meaning late subscribers immediately get the current user instead of waiting for the next event.
  • updateProfile sets the displayName on 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.currentUser reads from the BehaviorSubject synchronously. 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 for currentUser$ 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 check user.emailVerified in your guard
  • Password resetsendPasswordResetEmail(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


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.

Share

Comments (0)

Join the conversation

Sign in to leave a comment on this post.

No comments yet. to be the first!