Complete guide to implementing user authentication with Firebase Authentication in ShipSafe.
Overview
ShipSafe uses Firebase Authentication for secure user management. This tutorial covers:
- Email/password authentication - Traditional signup and login
- Google OAuth - Social login option
- Protected routes - Securing pages with authentication
- Session management - Handling user sessions
- User data access - Getting user information in components
Architecture
Authentication Flow
User → Client Component → Firebase Auth (Client SDK) → ID Token → API Route → Server Verification → Session
Key Points:
- Client-side: Firebase Client SDK handles login/signup
- Server-side: Firebase Admin SDK verifies tokens
- Session: ID tokens stored in client, verified on each request
Setup
1. Configure Firebase
See Firebase Setup Guide for complete instructions.
Required:
- Firebase project created
- Authentication enabled (Email/Password, Google optional)
- Environment variables configured
2. Environment Variables
Add to .env.local:
# Firebase Client Config (public)
NEXT_PUBLIC_FIREBASE_API_KEY=your_api_key
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
NEXT_PUBLIC_FIREBASE_PROJECT_ID=your-project-id
# Firebase Admin Config (server-only)
FIREBASE_CLIENT_EMAIL=your-service-account-email
FIREBASE_PRIVATE_KEY=your-private-key
Email/Password Authentication
Signup Flow
Step 1: User fills out signup form Step 2: Form validates input client-side Step 3: API route creates user in Firebase Auth Step 4: User document created in Firestore Step 5: User redirected to dashboard
Using SignupForm Component
The SignupForm component handles signup:
import SignupForm from "@/components/forms/SignupForm";
// In your auth page
<SignupForm
onSuccess={() => {
router.push("/dashboard");
}}
/>
Location: src/components/forms/SignupForm.tsx
Custom Signup Implementation
If you need custom signup logic:
"use client";
import { useState } from "react";
import { apiPost } from "@/lib/api";
import { signupSchema } from "@/features/auth/schema";
function CustomSignup() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setLoading(true);
try {
// Validate input
const data = signupSchema.parse({ email, password, displayName: "User" });
// Call signup API
const response = await apiPost("/api/auth/signup", data);
if (response.success) {
// Redirect to dashboard
router.push("/dashboard");
}
} catch (error) {
// Handle error
console.error("Signup failed:", error);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
</form>
);
}
Login Flow
Step 1: User enters email/password Step 2: Form validates input Step 3: API route verifies credentials Step 4: Session created (ID token stored) Step 5: User redirected to dashboard
Using LoginForm Component
The LoginForm component handles login:
import LoginForm from "@/components/forms/LoginForm";
// In your auth page
<LoginForm
onSuccess={() => {
router.push("/dashboard");
}}
/>
Location: src/components/forms/LoginForm.tsx
Custom Login Implementation
"use client";
import { useState } from "react";
import { apiPost } from "@/lib/api";
function CustomLogin() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
try {
const response = await apiPost("/api/auth/login", {
email,
password,
});
if (response.success) {
router.push("/dashboard");
}
} catch (error) {
console.error("Login failed:", error);
}
};
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
</form>
);
}
Google OAuth (Optional)
Setup
-
Enable Google provider in Firebase Console:
- Go to Authentication > Sign-in method
- Enable Google
- Configure OAuth consent screen
-
Add authorized domains in Firebase Console
-
Create Google Sign-In button:
"use client";
import { getAuthInstance } from "@/lib/firebase/client";
import { signInWithPopup, GoogleAuthProvider } from "firebase/auth";
function GoogleSignIn() {
const handleGoogleSignIn = async () => {
try {
const auth = getAuthInstance();
const provider = new GoogleAuthProvider();
// Sign in with Google
const result = await signInWithPopup(auth, provider);
const user = result.user;
// Get ID token
const idToken = await user.getIdToken();
// Send to server to create session
await apiPost("/api/auth/login", {
idToken,
});
router.push("/dashboard");
} catch (error) {
console.error("Google sign-in failed:", error);
}
};
return (
<button onClick={handleGoogleSignIn}>
Sign in with Google
</button>
);
}
Protected Routes
Middleware Protection
ShipSafe uses Next.js middleware to protect routes automatically.
Protected routes (defined in middleware.ts):
/dashboard/*- All dashboard pages/api/protected/*- Protected API routes
How it works:
- Middleware checks for authentication
- Redirects to
/authif not authenticated - Allows access if authenticated
Creating Protected Pages
Server Component
import { getCurrentUserServer } from "@/lib/firebase/auth";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
export default async function ProtectedPage() {
// Get request headers (Next.js App Router)
const headersList = await headers();
// Create request-like object for auth check
// Note: In a real implementation, you'd need to pass the actual request
// For now, check authentication client-side or use middleware
// Redirect if not authenticated
// (Middleware handles this automatically, but you can add additional checks)
return (
<div>
<h1>Protected Content</h1>
<p>This page requires authentication.</p>
</div>
);
}
Client Component
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { getAuthInstance } from "@/lib/firebase/client";
import { onAuthStateChanged } from "firebase/auth";
export default function ProtectedPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<any>(null);
useEffect(() => {
const auth = getAuthInstance();
// Listen to auth state changes
const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
if (currentUser) {
setUser(currentUser);
} else {
// Not authenticated, redirect to login
router.push("/auth");
}
setLoading(false);
});
return () => unsubscribe();
}, [router]);
if (loading) {
return <div>Loading...</div>;
}
if (!user) {
return null; // Will redirect
}
return (
<div>
<h1>Welcome, {user.email}!</h1>
<p>Protected content here.</p>
</div>
);
}
Using Dashboard Layout
The dashboard layout automatically protects all routes under /dashboard:
// src/app/dashboard/layout.tsx
// Already configured - just create pages under /dashboard
Example:
// src/app/dashboard/settings/page.tsx
export default function SettingsPage() {
// This page is automatically protected by middleware
// No additional auth check needed
return (
<div>
<h1>Settings</h1>
<p>This page is protected.</p>
</div>
);
}
Accessing User Data
In Server Components
For server components, use the API route to get user data:
// Server Component
async function getUserData() {
// Fetch from API route
const response = await fetch("/api/user/me", {
headers: {
Authorization: `Bearer ${idToken}`, // Get token from client
},
});
const data = await response.json();
return data.data;
}
Note: Server Components in Next.js App Router don't have direct request access. Use API routes or client components for auth checks.
In Client Components
"use client";
import { useState, useEffect } from "react";
import { getAuthInstance } from "@/lib/firebase/client";
import { onAuthStateChanged } from "firebase/auth";
import { apiGet } from "@/lib/api";
export default function UserProfile() {
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const auth = getAuthInstance();
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
if (firebaseUser) {
// Get full user data from API
try {
const response = await apiGet("/api/user/me");
if (response.success && response.data) {
setUser(response.data);
}
} catch (error) {
console.error("Failed to fetch user data:", error);
}
} else {
setUser(null);
}
setLoading(false);
});
return () => unsubscribe();
}, []);
if (loading) return <div>Loading...</div>;
if (!user) return <div>Not authenticated</div>;
return (
<div>
<h1>{user.displayName || user.email}</h1>
<p>Email: {user.email}</p>
<p>Role: {user.role}</p>
</div>
);
}
Logout
Using API Route
"use client";
import { apiPost } from "@/lib/api";
import { useRouter } from "next/navigation";
import { getAuthInstance } from "@/lib/firebase/client";
import { signOut } from "firebase/auth";
async function handleLogout() {
try {
// Sign out from Firebase (client-side)
const auth = getAuthInstance();
await signOut(auth);
// Call logout API (server-side cleanup)
await apiPost("/api/auth/logout");
// Redirect to home
router.push("/");
} catch (error) {
console.error("Logout failed:", error);
}
}
Password Reset
Request Reset
"use client";
import { apiPost } from "@/lib/api";
import { sendPasswordResetSchema } from "@/features/auth/schema";
async function requestPasswordReset(email: string) {
try {
// Validate email
const data = sendPasswordResetSchema.parse({ email });
// Send reset request
const response = await apiPost("/api/auth/reset", data);
if (response.success) {
alert("Password reset email sent!");
}
} catch (error) {
console.error("Password reset failed:", error);
}
}
Verify Reset Code
"use client";
import { apiPost } from "@/lib/api";
import { verifyPasswordResetSchema } from "@/features/auth/schema";
async function resetPassword(oobCode: string, newPassword: string) {
try {
// Validate input
const data = verifyPasswordResetSchema.parse({
oobCode,
newPassword,
});
// Verify and reset password
const response = await apiPost("/api/auth/reset/verify", data);
if (response.success) {
alert("Password reset successful! Please sign in.");
router.push("/auth");
}
} catch (error) {
console.error("Password reset verification failed:", error);
}
}
API Routes
Protected API Route
import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/firebase/auth";
export async function GET(req: NextRequest) {
try {
// Verify authentication (throws if not authenticated)
const user = await requireAuth(req);
// User is authenticated, proceed with logic
return NextResponse.json({
success: true,
data: {
message: `Hello, ${user.email}!`,
},
});
} catch (error) {
if (error instanceof Error && error.message.includes("Unauthorized")) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
);
}
return NextResponse.json(
{ error: "Server error" },
{ status: 500 }
);
}
}
Optional Authentication
import { getCurrentUserServer } from "@/lib/firebase/auth";
export async function GET(req: NextRequest) {
// Get user (returns null if not authenticated)
const user = await getCurrentUserServer(req);
if (user) {
// User is authenticated - show personalized content
return NextResponse.json({
success: true,
data: { personalized: true, user: user.email },
});
}
// User is not authenticated - show public content
return NextResponse.json({
success: true,
data: { personalized: false },
});
}
Best Practices
1. Always Validate Input
Use Zod schemas for all inputs:
import { loginSchema } from "@/features/auth/schema";
const data = loginSchema.parse(req.body);
2. Handle Errors Gracefully
try {
// Auth operation
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("auth/invalid-email")) {
return { error: "Invalid email address" };
}
// Handle other Firebase errors
}
}
3. Check Authentication State
Always check if user is authenticated before showing protected content:
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
setUser(user);
setLoading(false);
});
return () => unsubscribe();
}, []);
4. Protect Sensitive Operations
Always verify authentication server-side:
// ✅ Good: Verify on server
const user = await requireAuth(req);
// ❌ Bad: Trust client-side auth state only
// Always verify server-side for sensitive operations
Troubleshooting
"Unauthorized" Error
- Check if user is authenticated (token present)
- Verify token hasn't expired
- Check Firebase Admin SDK configuration
Redirect Loop
- Verify middleware configuration
- Check auth state in client components
- Ensure redirect URLs are correct
Token Expired
- Tokens expire after 1 hour
- Implement token refresh logic
- Handle expired tokens gracefully
Learn More
- Authentication Features - Complete auth guide
- Firebase Setup - Firebase configuration
- Protected Pages Tutorial - Securing routes
- API Routes Tutorial - Creating authenticated endpoints