AI features provide immense value for the end user, but costs money to run. Left unsecured, these features can be abused, but Firebase has multiple ways to protect AI endpoints.
In this example application, I use the nano banana image model to generate a virtual try on experience for shoppers to my website. This app lets shoppers to virtually try on outfits to help better visualize what that outfit would look like on them. This high-value AI endpoint, if unsecured, could lead to prohibitive costs from unlimited outfit generation. The following are measures that were implemented to prevent abuse.
Securing the endpoint from non-authorized clients
We start by securing our endpoint from unauthorized clients. This can help limit the amount of requests that come in via cURL, resellers, and other sites that are not approved to access this endpoint. To do this, we implement App Check, which is designed to help attest that requests to our AI endpoints are coming from real users on real devices. This means that as a user visits our site, we can run a quick attestation in the background and surface the virtual try on button as we test whether the user passes attestation checks. This helps limit the experience to only valid users.
// only show the btn if attestation passes
getLimitedUseToken(appCheck).then(() => {
tryOnBtn.style.display = 'block';
}).catch((error) => {
console.error('Failed to get limited use token:', error);
});
async function handleVirtualTryOnClick() {
const response = await fetch(`$MY_DOMAIN/tryItOn`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${idToken}`,
// send latest token
'X-Firebase-AppCheck': (await getLimitedUseToken(appCheck)).token
},
body: JSON.stringify({data: { productSku }}),
});
} interface FirebaseContext {
auth: {
uid: string;
},
appCheck: string;
}
const firebaseContextProvider: ContextProvider<FirebaseContext> = async (req: RequestData) => {
let context: FirebaseContext = {
auth: {uid: ""},
appCheck: "",
}
const authHeader = req.headers['authorization'];
const appCheckHeader = req.headers['x-firebase-appcheck'];
if (authHeader) {
const authToken = authHeader.replace("Bearer", "").trim();
const firebaseAuthToken = await getAuth().verifyIdToken(authToken, true)
context.auth.uid = firebaseAuthToken.uid;
}
if (appCheckHeader) {
context.appCheck = appCheckHeader;
}
return context;
}
const expressApp = express();
// The tryItOn handler with a context provider to decode firebase context
expressApp.post(
'/tryItOn',
expressHandler(
virtualTryOn, {
contextProvider: firebaseContextProvider
}
)
); Securing the endpoint from replayed requests
Having App Check can help limit our exposure to unauthorized clients, but what if we wanted something that prevented the users from making multiple requests by reusing the same token over and over again? This is where using a replay protected token comes in. In the code displayed above, we make a call to (await getLimitedUseToken(appCheck)).token to append to our request header a fresh, limited-use token. This means that each time we make a request to our endpoint, we request a new token that can be invalidated by the server for replay protected endpoint. Once our server receives the request, we ask App Check to validate and consume the limited-use token by calling verifyToken(appCheckToken, {consume: true}) and App Check will mark the token as having already been consumed.
export const virtualTryOn = ai.defineFlow({
//... ommitted for brevity
},
async ({ productSku }, {context}) => {
if (context?.appCheck === "") {
throw new UserFacingError("UNAUTHENTICATED", "no app check");
}
const appCheckToken = await getAppCheck(app).verifyToken(
context!.appCheck,
{ consume: true }
);
if (appCheckToken.alreadyConsumed) {
throw new UserFacingError("UNAUTHENTICATED", "already consumed request");
}
//... ommitted for brevity
}); Securing the endpoint from non-authenticated users
While this may seem like a comprehensive solution, we also want to make sure that we don’t just allow any user to make a request to our backend. We should also check whether the user is authenticated and allowed to make these types of requests. In our example App Check code, you may have noticed that we also use the firebaseContextProvider to access the authentication token and extract its uid. If we wanted to check for additional settings, like only allowing email verified users access to this feature, we could additionally make a request to firebaseAuthToken.email_verified and throw an error if the user does not have a verified email.
Securing the endpoint from too many requests by rate limiting
By having authenticated users, we can then additionally apply some rate limiting on those users ourselves. Since we are running our AI inference in a server environment, we can record the number of requests that a user makes in an hour, and place a limit on that number. In the full server side code sample at the end of this post, we’ve included a small function to go and help us rate limit user requests. Using this rate limiter, we can allow requests as long as they do not exceed the maximum number of requests within one hour. Having this reset each hour allows customers to come back and continue using our site. We can also surface this error to the users letting them know that in one hour they can try again. Any third party rate limiting tool can be used here.
Limiting the input into our virtual try on function
Finally, as a best practice, we limit what our API endpoint accepts. If someone were to reconstruct the request and try to pass in their own inputs, we could potentially have our AI system serve results that would not be for their originally intended purpose. For instance, if we accept two images and a prompt as the input, the end user may insert their own images as well as their own prompt, effectively exploiting our API surface for their own means. With our implementation, we limit requests to just the sku. The server is then responsible for fetching the user uploaded profile image, the product sku image, and the prompt associated with that sku. This helps limit prompt injection attacks since the end user cannot insert custom language into the image generator or their own images outside of their profile image into the image generator.
Summary
By implementing a few changes to how our Genkit flow is exposed, we are able to limit the abuse vectors in our application. Here is a quick recap:
- We use App Check to help ensure legitimate user from legitimate devices are making requests. Use App Check to help limit the amount of requests to a single request per valid attestation.
- Use user authentication to help protect endpoints from anyone on the internet making requests and use a rate limiter to help limit those requests on a per user basis.
- Use well defined inputs to help limit prompt injection attacks so a user cannot treat our application endpoint as a general purpose image generation model.
With these changes, we have a more secure AI endpoint that helps shield ourselves from additional abuse while providing a useful feature to our users.
Expand to see the full server code
import { vertexAI } from '@genkit-ai/vertexai';
import parseDataURL from 'data-urls';
import { genkit, UserFacingError, z } from 'genkit';
import { getApp, initializeApp } from 'firebase-admin/app';
import { getFirestore, Timestamp, Transaction } from 'firebase-admin/firestore';
import { getStorage } from 'firebase-admin/storage';
import { getAppCheck, type DecodedAppCheckToken } from 'firebase-admin/app-check';
import { expressHandler } from '@genkit-ai/express';
import express from 'express';
import cors from 'cors';
import type { ContextProvider, RequestData } from 'genkit/context';
import { getAuth } from 'firebase-admin/auth';
const app = initializeApp({
projectId: firebaseConfig.projectId,
storageBucket: firebaseConfig.storageBucket
});
/** start rate limiter */
const MAX_REQUEST_PER_HOUR = 5;
const REQUEST_TIME_FIELD = "request_timestamp";
const db = getFirestore()
const TOKEN_FIELD = "tokens";
const TTL_FIELD = "ttl";
const getUserRateLimitCollection = (userId: string) => {
return db.collection(`/users/${userId}/rateLimit`);
};
const futureHourAsTimestamp = (): Timestamp => {
const date = new Date();
date.setHours(date.getHours() + 1);
return Timestamp.fromDate(date);
};
const getLastHourAsTimestamp = (): Timestamp => {
const date = new Date();
date.setHours(date.getHours() - 1);
return Timestamp.fromDate(date);
};
const updateTokens = async (
userId: string,
tokens = 0
) => {
return db.runTransaction(async (transaction) => {
const userRateCollection = getUserRateLimitCollection(userId);
return transaction.create(
userRateCollection.doc(),
{
[TOKEN_FIELD]: tokens,
[REQUEST_TIME_FIELD]: Timestamp.now(),
[TTL_FIELD]: futureHourAsTimestamp(),
});
});
};
const countOfRequestsTimeFrame = async (userId: string) => {
return db.runTransaction(async (transaction: Transaction) => {
const userRateCollection = getUserRateLimitCollection(userId);
const query = userRateCollection.where(
REQUEST_TIME_FIELD, ">", getLastHourAsTimestamp());
const sumEntries = query.count();
return transaction.get(sumEntries).then((result) => {
return Promise.resolve(result.data().count);
});
});
};
const canMakeRequest = async (userId: string): Promise<boolean> => {
const countOfReq = await countOfRequestsTimeFrame(userId);
if (countOfReq >= MAX_REQUEST_PER_HOUR) {
return false;
}
await updateTokens(userId);
return true;
};
/** end rate limiter */
// Initialize Genkit with the Vertex AI plugin
const ai = genkit({
plugins: [vertexAI()],
model: vertexAI.model('gemini-2.5-flash', {
temperature: 0.8,
}),
});
// Define input schema
const VirtualTryOnInputSchema = z.object({
productSku: z
.string()
.describe('The product sku for the virtual try on experience')
});
const VirtualTryOnSchema = z.object({
virtualTryOnOutputUrl: z
.string()
.describe('The output image url of the virtual try on experience')
});
export const virtualTryOn = ai.defineFlow({
name: 'virtualTryOnFlow',
inputSchema: VirtualTryOnInputSchema,
outputSchema: VirtualTryOnSchema
},
async ({ productSku }, {context}) => {
console.log("context", context)
// const context = {auth: {uid: "1"}};
if (context?.auth?.uid === "") {
throw new UserFacingError("UNAUTHENTICATED", "unauthenticated");
}
if (context?.appCheck === "") {
throw new UserFacingError("UNAUTHENTICATED", "no app check");
}
const appCheckToken = await getAppCheck(app).verifyToken(
context!.appCheck, { consume: true }
);
if (appCheckToken.alreadyConsumed) {
throw new UserFacingError("UNAUTHENTICATED", "already consumed request");
}
const existingImg = await loadExistingImg(context!.auth!.uid, productSku);
if (existingImg) {
return { virtualTryOnOutputUrl: existingImg.url };
}
if(!await canMakeRequest(context!.auth!.uid)) {
throw new UserFacingError("PERMISSION_DENIED",
"Quota exceeded. Please wait 1 hour before making additional requests.")
}
const userImg = await loadUserImg(context!.auth!.uid);
const productImg = await loadProductImg(productSku);
const imageGenPrompt = await loadImageGenPrompt(productSku);
const response = await ai.generate({
model: vertexAI.model('gemini-2.5-flash-image'),
prompt: [
{ media: { url: productImg.url } },
{ media: { url: userImg.url } },
{ text: imageGenPrompt }
],
output: { format: 'media' }
});
if (response?.media?.url) {
const parsed = parseDataURL(response.media.url);
if (parsed) {
await writeImageToCloudStorage(parsed, context!.auth!.uid, productSku);
return { virtualTryOnOutputUrl: response.media.url }
}
}
throw new UserFacingError("INTERNAL", 'could not generate image');
});
async function writeImageToCloudStorage(
parsed: ReturnType<typeof parseDataURL>,
uid: string, productSku: string): Promise<void> {
if (!parsed) {
throw new Error('no image data to write');
}
const bucket = getStorage(app).bucket();
const extension = parsed.mimeType.essence.split('/')[1] || 'png';
const filePath = `profiles/imgs/${uid}/${productSku}.${extension}`;
const file = bucket.file(filePath);
await file.save(parsed.body, {
metadata: {
contentType: parsed.mimeType.essence,
},
});
}
async function loadUserImg(uid: string) {
const bucket = getStorage(app).bucket();
const img = bucket.file(`profiles/imgs/${uid}/profile.png`);
const data = await img.download();
const [metadata] = await img.getMetadata();
const contentType = metadata.contentType || 'image/png';
return {
url: `data:${contentType};base64,${data[0].toString('base64')}`
};
}
async function loadProductImg(sku: string) {
const bucket = getStorage().bucket();
const files = (
await bucket.getFiles({ prefix: `products/imgs/${sku}.png` }))[0];
if (files.length === 0) {
throw new UserFacingError(
'NOT_FOUND', `could not find product image for sku ${sku}`);
}
const img = files[0];
const data = await img?.download();
const [metadata] = await img!.getMetadata();
const contentType = metadata.contentType || 'image/png';
return {
url: `data:${contentType};base64,${data![0].toString('base64')}`
};
}
async function loadImageGenPrompt(sku: string): Promise<string> {
const db = getFirestore(app);
const doc = await db.collection('products').doc(sku).get();
if (!doc.exists) {
throw new UserFacingError(
'NOT_FOUND', `could not find product with sku ${sku}`);
}
const data = doc.data();
if (!data) {
throw new UserFacingError(
'NOT_FOUND', `no data for product with sku ${sku}`);
}
return data.imageGenDesc
}
async function loadExistingImg(
uid: string, productSku: string): Promise<{ url: string } | null> {
const bucket = getStorage().bucket();
const prefix = `profiles/imgs/${uid}/${productSku}.`;
const [files] = await bucket.getFiles({ prefix });
if (files.length === 0) {
return null;
}
const img = files[0];
const [data, metadata] = await Promise.all(
[img!.download(), img!.getMetadata()]);
const contentType = metadata[0].contentType || 'image/png';
return {
url: `data:${contentType};base64,${data[0].toString('base64')}`
};
}
interface FirebaseContext {
auth: {
uid: string;
},
appCheck: string;
}
const firebaseContextProvider: ContextProvider<FirebaseContext> = async (
req: RequestData) => {
let context: FirebaseContext = {
auth: {uid: ""},
appCheck: "",
}
console.log(req.headers)
const authHeader = req.headers['authorization'];
const appCheckHeader = req.headers['x-firebase-appcheck'];
if (authHeader) {
const authToken = authHeader.replace("Bearer", "").trim();
const firebaseAuthToken =await getAuth().verifyIdToken(authToken, true)
context.auth.uid = firebaseAuthToken.uid;
}
if (appCheckHeader) {
context.appCheck = appCheckHeader;
}
return context;
}
const expressApp = express();
expressApp.use(cors());
expressApp.use(express.json());
expressApp.post(
'/tryItOn',
expressHandler(
virtualTryOn, {
contextProvider: firebaseContextProvider
}
)
);
expressApp.listen(8080, () => {
console.log('Express server listening on port 8080');
}); 