Creating a robust and secure REST API is paramount for modern web applications. When building with TypeScript and Express.js, incorporating a reliable authentication mechanism is a critical step. Firebase Authentication offers a powerful and flexible solution, and utilizing Firebase JSON Web Tokens (JWTs) provides a secure way to authenticate requests to your backend.
This guide will walk you through the process of setting up a TypeScript Express REST API and securing it using Firebase JWTs. We will cover the essential steps from project setup to implementing middleware for token verification, ensuring that only authenticated users can access protected endpoints.
Before we dive into implementing authentication, we need to set up a basic TypeScript and Express.js project. This involves initializing a Node.js project, installing the necessary dependencies, and configuring TypeScript.
First, create a new directory for your project and initialize a Node.js project:
mkdir firebase-express-auth-api
cd firebase-express-auth-api
npm init -y
Next, install Express, TypeScript, and the necessary type definitions:
npm install express typescript @types/express @types/node
We will also need the Firebase Admin SDK to verify the JWTs on the server-side:
npm install firebase-admin
Initialize your TypeScript configuration file:
npx tsc --init
In your tsconfig.json
file, you might want to adjust some settings, such as the outDir
for compiled JavaScript files and the esModuleInterop
flag for better module compatibility.
Create a basic Express application entry point, for example, src/index.ts
:
import express from 'express';
const app = express();
const port = process.env.PORT || 3000;
app.use(express.json());
app.get('/', (req, res) => {
res.send('Hello, Secure API!');
});
app.listen(port, () => {
console.log(<code>Server running on port ${port}
);
});
Compile your TypeScript code and run the application:
npx tsc
node dist/index.js
You should see "Server running on port 3000" in your console.
To use Firebase Authentication in your backend, you need to set up a Firebase project and obtain service account credentials.
Go to the Firebase console and create a new project or use an existing one. Navigate to "Project settings" > "Service accounts". Click "Generate new private key" to download a JSON file containing your service account credentials. Keep this file secure and do not expose it publicly.
In your Express application, initialize the Firebase Admin SDK using these credentials:
import express from 'express';
import admin from 'firebase-admin';
// Replace with the path to your service account key file
const serviceAccount = require('../path/to/your/serviceAccountKey.json');
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});
const app = express();
const port = process.env.PORT || 3000;
app.use(express.json());
app.get('/', (req, res) => {
res.send('Hello, Secure API!');
});
app.listen(port, () => {
console.log(<code>Server running on port ${port}
);
});
Ensure you replace ../path/to/your/serviceAccountKey.json
with the actual path to your downloaded JSON file.
Illustrative diagram of backend authentication with Firebase.
The core of securing your API with Firebase JWTs lies in creating middleware that intercepts incoming requests, extracts the JWT, and verifies its authenticity using the Firebase Admin SDK.
Create a new file, for example, src/middleware/auth.ts
, for your authentication middleware:
import { Request, Response, NextFunction } from 'express';
import admin from 'firebase-admin';
export const authenticateToken = async (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Extract token from "Bearer TOKEN"
if (token == null) {
return res.sendStatus(401); // If there's no token, return 401 Unauthorized
}
try {
const decodedToken = await admin.auth().verifyIdToken(token);
// Attach the decoded token to the request object for later use
(req as any).user = decodedToken;
next(); // Proceed to the next middleware or route handler
} catch (error) {
console.error('Error verifying Firebase ID token:', error);
res.sendStatus(403); // If token is invalid, return 403 Forbidden
}
};
This middleware function performs the following:
Authorization
header, expecting the format Bearer TOKEN
.
admin.auth().verifyIdToken(token)
to verify the token with Firebase. This function also checks if the token is expired.
next()
.
Now, apply this middleware to the routes you want to protect in your src/index.ts
file:
import express from 'express'; import admin from 'firebase-admin'; import { authenticateToken } from './middleware/auth'; // Import the middleware // Replace with the path to your service account key file const serviceAccount = require('../path/to/your/serviceAccountKey.json'); admin.initializeApp({ credential: admin.credential.cert(serviceAccount) }); const app = express(); const port = process.env.PORT || 3000; app.use(express.json()); app.get('/', (req, res) => { res.send('Hello, Secure API!'); }); // Protected route app.get('/protected', authenticateToken, (req, res) => { // Access user information from the decoded token attached by the middleware const firebaseUser = (req as any).user; res.send(<code>Hello, authenticated user! Your Firebase UID is: ${firebaseUser.uid}
); }); app.listen(port, () => { console.log(Server running on port ${port}
); });
In this example, the /protected
route is now secured by the authenticateToken
middleware. Any request to this route without a valid Firebase JWT in the Authorization
header will be rejected with a 401 or 403 status code.
Flow of Firebase Authentication with a custom backend.
On the client-side (your web or mobile application), you will use the Firebase client SDK to authenticate users and obtain the JWT (specifically, the ID token) to send with requests to your protected backend API.
After a user successfully signs in using any of Firebase's authentication methods (e.g., email/password, Google Sign-In), you can get the user's ID token:
import firebase from 'firebase/app';
import 'firebase/auth';
// Initialize Firebase on the client-side
const firebaseConfig = {
// Your Firebase client configuration
};
firebase.initializeApp(firebaseConfig);
// After a user signs in successfully
firebase.auth().currentUser.getIdToken()
.then((idToken) => {
// Use this idToken to authenticate requests to your backend
console.log("Firebase ID Token:", idToken);
})
.catch((error) => {
console.error("Error getting ID token:", error);
});
The getIdToken()
method retrieves the current user's ID token. This token should be included in the Authorization
header of your requests to the protected endpoints of your Express API, in the format Bearer <idToken>
.
When making requests from your client to your protected Express endpoints, include the obtained ID token in the Authorization
header:
const idToken = 'YOUR_FIREBASE_ID_TOKEN'; // Get this from firebase.auth().currentUser.getIdToken()
fetch('/protected', {
method: 'GET',
headers: {
'Authorization': <code>Bearer ${idToken}
,
'Content-Type': 'application/json',
},
})
.then(response => {
if (response.ok) {
return response.text();
}
throw new Error('Request failed with status ' + response.status);
})
.then(data => {
console.log('Protected data:', data);
})
.catch(error => {
console.error('Error accessing protected endpoint:', error);
});
Your Express backend, with the authenticateToken
middleware, will then verify this token and allow or deny the request accordingly.
While the basic setup provides a good starting point, there are additional considerations for building a secure and scalable API.
Firebase allows you to add custom claims to a user's ID token using the Admin SDK. This is useful for storing user roles or permissions directly within the token, allowing for role-based access control (RBAC) in your API.
// Example using Firebase Admin SDK to set custom claims
admin.auth().setCustomUserClaims(uid, { role: 'admin' })
.then(() => {
// Custom claims set. The user's ID token will now contain these claims.
})
.catch((error) => {
console.error('Error setting custom claims:', error);
});
In your authentication middleware or route handlers, you can access these custom claims from the decoded token to implement authorization logic.
import { Request, Response, NextFunction } from 'express';
import admin from 'firebase-admin';
export const authenticateToken = async (req: Request, res: Response, next: NextFunction) => {
// ... (token extraction and verification as before)
try {
const decodedToken = await admin.auth().verifyIdToken(token);
(req as any).user = decodedToken;
// Example of checking for a custom claim
if ((decodedToken as any).role === 'admin') {
next(); // User is an admin, allow access
} else {
res.sendStatus(403); // User is not an admin, deny access
}
} catch (error) {
console.error('Error verifying Firebase ID token:', error);
res.sendStatus(403); // If token is invalid, return 403 Forbidden
}
};
Firebase ID tokens are short-lived and automatically refreshed by the Firebase client SDK. Your client application should handle token refreshing and ensure that the latest token is used for API requests.
Store sensitive information, such as the path to your service account key file, in environment variables rather than directly in your code. This is crucial for security, especially when deploying your API.
// Use environment variable for the service account file path
const serviceAccountPath = process.env.FIREBASE_SERVICE_ACCOUNT_KEY_PATH;
if (!serviceAccountPath) {
throw new Error('FIREBASE_SERVICE_ACCOUNT_KEY_PATH environment variable is not set.');
}
const serviceAccount = require(serviceAccountPath);
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});
Implement comprehensive error handling and logging in your API to monitor authentication failures and other issues.
This video provides a visual guide to implementing JWT authentication with Firebase for your API, complementing the concepts discussed in this guide. It can help solidify your understanding of the practical steps involved in securing your endpoints.
While JWT authentication is a popular choice for REST APIs, it's helpful to understand its place among other authentication methods.
Method | Description | Pros | Cons |
---|---|---|---|
JWT (JSON Web Token) | Tokens containing claims about the user, signed to prevent tampering. Client stores the token and sends it with each request. | Stateless, scalable, widely supported. Can include custom claims. | Token size can increase with many claims. Requires secure storage on the client. Token revocation can be complex. |
Session-Based Authentication | Server creates a session upon login and stores session information. Client receives a session ID (e.g., in a cookie). | Simpler to implement for basic cases. Easy to revoke sessions. | Stateful (server needs to maintain session state). Can be less scalable in distributed systems. Vulnerable to CSRF attacks if not mitigated. |
API Keys | Static keys provided to clients to access specific API endpoints. | Simple to implement for basic access control. | Less secure for user authentication. Difficult to manage per-user permissions. Keys can be compromised. |
Firebase JWTs offer a good balance of security, scalability, and ease of use for authenticating users in a TypeScript Express API.
A JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. Firebase uses JWTs (specifically ID tokens) to securely transmit information about the authenticated user from the Firebase authentication service to your backend. This allows your backend to verify the identity of the user making a request without needing to directly communicate with Firebase for every request.
Firebase ID tokens have a short lifespan (typically one hour). The Firebase client SDK automatically handles refreshing these tokens. Your client application should ensure it always uses the current user's latest ID token when making requests to your backend. On the backend, the verifyIdToken
function automatically checks for token expiration.
Firebase client SDKs typically store the ID token in browser storage (like IndexedDB or Local Storage). While this is generally considered safe for tokens with short lifespans and transmitted over HTTPS, it's important to be aware of potential risks like XSS attacks. Using HTTP-only cookies for storing session tokens (derived from JWTs) can offer additional security, but Firebase's default approach is common for SPA-based applications.
Yes, Firebase allows you to create custom authentication systems using custom tokens. You can mint custom tokens on your backend (using the Admin SDK) after verifying user credentials through your own logic, and then use signInWithCustomToken
on the client-side to authenticate with Firebase and receive an ID token.
Once a user is authenticated using their Firebase JWT, you can implement authorization logic in your backend. This can involve checking custom claims in the decoded JWT (as discussed earlier), querying your database for user roles or permissions, or using a dedicated authorization library.