Complete guide to authentication security in ShipSafe, explaining secure patterns and showcasing Firebase token verification implementation.
Overview
ShipSafe uses Firebase Authentication with secure server-side token verification, custom claims for role-based access, and defense-in-depth security patterns.
Security Principles:
- ✅ Server-side verification - All tokens verified using Admin SDK
- ✅ Token expiration - Tokens expire automatically
- ✅ Custom claims - Role-based access control
- ✅ Secure storage - No sensitive data in client
- ✅ Session management - Secure session handling
Concept: Token-Based Authentication
How Firebase Authentication Works
- Client Authentication: User signs in via Firebase Client SDK
- ID Token: Firebase issues signed ID token
- Token Transmission: Client sends token with requests
- Server Verification: Server verifies token using Admin SDK
- Authorization: Server checks roles/permissions from token
Why Server-Side Verification Matters
Client-side tokens can be:
- ✅ Verified but not trusted alone
- ✅ Expired or revoked
- ✅ Tampered with (signature invalidates them)
- ❌ Used without server verification (security risk)
Server-side verification ensures:
- ✅ Token signature is valid
- ✅ Token hasn't expired
- ✅ Token hasn't been revoked
- ✅ User claims are authentic
Implementation: Token Verification
Code Showcase
Location: src/lib/firebase/auth.ts
/**
* Server-Side Authentication - Token verification using Firebase Admin SDK
*/
import { NextRequest } from "next/server";
import { getAuth } from "firebase-admin/auth";
import { getAdminApp } from "./init";
// Server-side guard - prevent client-side usage
if (typeof window !== "undefined") {
throw new Error(
"❌ Firebase auth helpers cannot be used in client-side code. " +
"Use @/lib/firebase/client.ts for client authentication."
);
}
// Extract ID token from request
function extractIdToken(req: NextRequest): string | null {
// Method 1: Authorization header (recommended)
const authHeader = req.headers.get("authorization");
if (authHeader?.startsWith("Bearer ")) {
return authHeader.substring(7);
}
// Method 2: Cookie (fallback)
const tokenCookie = req.cookies.get("firebase_token")?.value;
if (tokenCookie) {
return tokenCookie;
}
return null;
}
// Verify ID token using Firebase Admin SDK
export async function verifyIdToken(token: string): Promise<DecodedIdToken> {
try {
const adminAuth = getAuth(getAdminApp());
// Verify token signature and expiration
// true = check token revocation
const decodedToken = await adminAuth.verifyIdToken(token, true);
return decodedToken as DecodedIdToken;
} catch (error) {
// Provide safe error messages (don't leak secrets)
if (error instanceof Error) {
if (error.message.includes("expired")) {
throw new Error("Authentication token has expired. Please sign in again.");
}
if (error.message.includes("revoked")) {
throw new Error("Authentication token has been revoked. Please sign in again.");
}
if (error.message.includes("invalid")) {
throw new Error("Invalid authentication token.");
}
}
throw new Error("Failed to verify authentication token.");
}
}
// Get authenticated user from request (main helper)
export async function getCurrentUserServer(
req: NextRequest
): Promise<AuthenticatedUser | null> {
try {
// 1. Extract token from request
const token = extractIdToken(req);
if (!token) {
return null; // No token provided
}
// 2. Verify token signature and expiration
const decodedToken = await verifyIdToken(token);
// 3. Transform to authenticated user object
return {
uid: decodedToken.uid,
email: decodedToken.email || null,
emailVerified: decodedToken.email_verified || false,
name: decodedToken.name || null,
picture: decodedToken.picture || null,
customClaims: extractCustomClaims(decodedToken), // Roles, permissions
};
} catch (error) {
// Return null on any error (invalid, expired, revoked)
return null;
}
}
// Require authentication (throws if not authenticated)
export async function requireAuth(req: NextRequest): Promise<AuthenticatedUser> {
const user = await getCurrentUserServer(req);
if (!user) {
throw new Error("Unauthorized: Authentication required.");
}
return user;
}
API Route Usage
// src/app/api/user/me/route.ts
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
return NextResponse.json({
success: true,
data: {
uid: user.uid,
email: user.email,
role: user.customClaims?.role,
},
});
} catch (error) {
if (error instanceof Error && error.message.includes("Unauthorized")) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
);
}
// Handle other errors...
}
}
Middleware Authentication Patterns
ShipSafe's middleware implements smart authentication handling that differentiates between API routes and page routes:
API Routes: Return 401 Unauthorized
For API routes (/api/*), middleware returns 401 Unauthorized (JSON response):
// middleware.ts
if (isProtected && !session && path.startsWith("/api")) {
logSecurityEvent(req, "api_auth_required");
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
Why 401 for APIs?
- ✅ API clients expect proper HTTP status codes (not redirects)
- ✅ Allows programmatic error handling
- ✅ Follows REST API conventions
- ✅ Enables proper error responses in JSON format
Example API Response:
{
"error": "Unauthorized"
}
Status: 401 Unauthorized
Page Routes: Redirect to Auth
For page routes (non-API), middleware redirects to /auth:
// middleware.ts
if (isProtected && !session && !path.startsWith("/api")) {
url.pathname = "/auth";
url.searchParams.set("from", path); // Preserve intended destination
logSecurityEvent(req, "auth_redirect");
return NextResponse.redirect(url);
}
Why redirect for pages?
- ✅ Better user experience (seamless redirect)
- ✅ Preserves intended destination (
?from=/dashboard) - ✅ Allows automatic redirect after login
- ✅ Follows web application conventions
Example Redirect:
GET /dashboard (no session)
→ Redirect to /auth?from=/dashboard
→ After login, redirect back to /dashboard
Excluded Routes
Certain routes are excluded from authentication requirements:
- ✅ Webhook routes (
/api/webhooks/*) - Use signature verification - ✅ Auth API routes (
/api/auth/*) - Handle their own authentication - ✅ Public API routes (
/api/csrf,/api/security/status) - Intentionally public
Why exclusions?
- Webhooks can't include session cookies (external services)
- Auth endpoints need to handle login/signup flows
- Public endpoints provide system status information
Complete Middleware Pattern
// middleware.ts - Authentication Guard
const session = req.cookies.get("session")?.value;
// Identify excluded routes
const isWebhook = path.startsWith("/api/webhooks/");
const isAuthApi = path.startsWith("/api/auth/");
const isPublicApi = path.startsWith("/api/csrf") ||
path.startsWith("/api/security/status");
// Check if route requires authentication
const isProtected = PROTECTED_ROUTES.some((r) => path.startsWith(r));
// Apply authentication guard
if (isProtected && !session && !isWebhook && !isAuthApi && !isPublicApi) {
// API routes: Return 401
if (path.startsWith("/api")) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Page routes: Redirect to auth
url.pathname = "/auth";
url.searchParams.set("from", path);
return NextResponse.redirect(url);
}
Concept: Custom Claims & Role-Based Access
What are Custom Claims?
Custom claims are additional data attached to Firebase ID tokens:
- Roles - User role (admin, user, premium, etc.)
- Permissions - Feature access flags
- Metadata - Additional user attributes
Benefits:
- ✅ Stored in token (no database lookup needed)
- ✅ Automatically verified with token
- ✅ Cannot be tampered with (signed by Firebase)
How ShipSafe Uses Custom Claims
Setting Custom Claims:
// src/lib/firebase/admin.ts
import { getAuth } from "firebase-admin/auth";
export async function setCustomClaims(
uid: string,
claims: Record<string, unknown>
): Promise<void> {
const adminAuth = getAuth(getAdminApp());
// Set custom claims on user
await adminAuth.setCustomUserClaims(uid, claims);
// User must refresh ID token to get updated claims
}
Example: Setting User Role
// When user subscribes to premium plan
await setCustomClaims(userId, {
role: "premium",
subscriptionStatus: "active",
planId: "plan_premium",
});
Accessing Custom Claims:
// In API route
const user = await requireAuth(req);
const role = user.customClaims?.role; // "premium"
const subscriptionStatus = user.customClaims?.subscriptionStatus; // "active"
Secure Authentication Patterns
Pattern 1: Protected API Route
// src/app/api/protected/route.ts
import { requireAuth } from "@/lib/firebase/auth";
export async function POST(req: NextRequest) {
try {
// Must be authenticated
const user = await requireAuth(req);
// Access user data
const { uid, email, customClaims } = user;
const role = customClaims?.role;
// Check authorization
if (role !== "admin") {
return NextResponse.json(
{ error: "Forbidden: Admin access required" },
{ status: 403 }
);
}
// Proceed with admin-only logic
return NextResponse.json({ success: true });
} catch (error) {
// Handle auth errors
}
}
Pattern 2: Optional Authentication
// src/app/api/public/route.ts
import { getCurrentUserServer } from "@/lib/firebase/auth";
export async function GET(req: NextRequest) {
// Optional authentication
const user = await getCurrentUserServer(req);
if (user) {
// Authenticated user - personalized content
return NextResponse.json({
personalized: true,
userEmail: user.email,
});
}
// Not authenticated - public content
return NextResponse.json({
personalized: false,
});
}
Pattern 3: Role-Based Authorization
// src/app/api/admin/users/route.ts
export async function GET(req: NextRequest) {
const user = await requireAuth(req);
// Check role from custom claims
const role = user.customClaims?.role;
if (role !== "admin" && role !== "moderator") {
return NextResponse.json(
{ error: "Forbidden: Insufficient permissions" },
{ status: 403 }
);
}
// Authorized - proceed
// ...
}
Token Security Features
1. Token Expiration
Firebase ID tokens expire after 1 hour:
// Token automatically expires
{
exp: 1234567890, // Expiration timestamp
iat: 1234564290, // Issued at
}
Handling:
- Client refreshes token automatically
- Server rejects expired tokens
- User re-authenticates if refresh fails
2. Token Revocation
Tokens can be revoked (e.g., after password reset):
// Verify with revocation check
const decodedToken = await adminAuth.verifyIdToken(token, true);
// true = check if token was revoked
3. Token Signature Verification
Tokens are cryptographically signed by Firebase:
// Signature automatically verified by Admin SDK
const decodedToken = await adminAuth.verifyIdToken(token);
// ✅ Throws error if signature invalid
What This Prevents:
- Token tampering
- Forged tokens
- Replay attacks
Best Practices
1. Always Verify Server-Side
✅ Do:
// Verify token on server
const user = await requireAuth(req);
❌ Don't:
// Trust client-side auth state alone
// Always verify server-side for sensitive operations
2. Use Custom Claims for Authorization
✅ Do:
const role = user.customClaims?.role;
if (role !== "admin") {
return forbidden();
}
❌ Don't:
// Don't trust client-provided role
const role = req.body.role; // ❌ Can be forged
3. Handle Token Errors Gracefully
✅ Do:
try {
const user = await requireAuth(req);
} catch (error) {
if (error.message.includes("Unauthorized")) {
return NextResponse.json({ error: "Please sign in" }, { status: 401 });
}
}
4. Never Expose Admin SDK
✅ Do:
- Keep Admin SDK server-side only
- Use client SDK for client authentication
- Verify tokens server-side
❌ Don't:
- Import Admin SDK in client components
- Expose private keys to client
- Trust client-side auth state for authorization
Security Flow
Complete Authentication Flow
The authentication flow in ShipSafe follows a secure, token-based pattern:
Client-Side (Browser):
- User Signs In - User provides credentials via Firebase Client SDK
- Firebase Authenticates - Firebase validates credentials
- ID Token Issued - Firebase issues a signed JWT ID token
- Token Stored - Client stores token securely (memory or session storage)
- API Request - Client includes token in
Authorization: Bearer <token>header
Server-Side (API Route):
- Token Extraction - Server extracts token from request headers
- Signature Verification - Server verifies token signature using Firebase Admin SDK
- Expiration Check - Server validates token hasn't expired
- Revocation Check - Server checks if token has been revoked
- Claims Extraction - Server extracts user ID and custom claims (roles, permissions)
- Authorization - Server checks if user has required permissions
- Request Processed - If authorized, request is processed; otherwise, 403 Forbidden
Security Guarantees:
- ✅ Token signature cannot be forged (cryptographically signed)
- ✅ Expired tokens are automatically rejected
- ✅ Revoked tokens are immediately invalid
- ✅ Custom claims are verified server-side
- ✅ No sensitive data exposed to client
Example: Authenticated Request
POST /api/user/update
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
x-csrf-token: abc123...
{
"displayName": "New Name"
}
Server Processing:
// 1. Extract token from Authorization header
const token = extractIdToken(request);
// 2. Verify token signature using Admin SDK
const decodedToken = await adminAuth.verifyIdToken(token, true);
// ✅ Signature valid
// ✅ Not expired
// ✅ Not revoked
// 3. Extract user information
const user = {
uid: decodedToken.uid,
email: decodedToken.email,
role: decodedToken.role, // Custom claim
};
// 4. Authorize based on role/permissions
if (user.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// 5. Process authorized request
// ✅ Request processed successfully
Token Storage Security
Client-Side Storage Options
Option 1: Memory Only (Most Secure)
// Token stored only in memory (cleared on refresh)
let authToken: string | null = null;
Option 2: Session Storage
// Token cleared when tab closes
sessionStorage.setItem("token", token);
Option 3: Local Storage (Less Secure)
// Token persists across sessions
localStorage.setItem("token", token);
ShipSafe Recommendation:
- Store tokens in memory or session storage
- Avoid localStorage for sensitive tokens
- Use httpOnly cookies for session tokens (if needed)
Troubleshooting
Token Expired Error
Problem: "Authentication token has expired"
Solution:
- Client should refresh token automatically
- Implement token refresh logic
- Redirect to login if refresh fails
Token Invalid Error
Problem: "Invalid authentication token"
Solutions:
- Verify token is being sent correctly
- Check token hasn't been tampered with
- Ensure Admin SDK is configured correctly
- Verify Firebase project configuration
Custom Claims Not Updating
Problem: Custom claims not appearing in token
Solutions:
- User must refresh ID token after claims are set
- Call
getIdToken(true)to force refresh - Wait for token refresh (claims propagate within minutes)
Learn More
- Authentication Features - Complete auth guide
- Authentication Tutorial - Implementation tutorial
- Firebase Auth Documentation - Official docs