
In this guide, you will learn how to generate pre-signed URLs for Firebase Storage with Astro on Cloudflare Workers. You will go through the process of setting up a new Astro project, enabling server-side rendering using Cloudflare adapter, obtaining Firebase Service Account JSON and then creating functions to generate pre-signed URLs for retrieval and upload from Firebase Storage.
Prerequisites
To follow along, you will need:
- Node.js 20 or later
- A Firebase account
Table Of Contents
- Generate a Firebase Service Account JSON
- Create a new Astro application
- Integrate Cloudflare adapter in your Astro project
- Generate the pre-signed URLs
- 1. Access the Environment Variables
- 2. Generate Google OAuth 2.0 Token
- 3. Set up Firebase Storage Bucket CORS Policy
- 4. Create Pre-signed URLs with Cloud Storage APIs
- 5. Pre-signed URL to GET a Firebase Object (retrieve)
- 6. Pre-signed URL to PUT a Firebase Object (upload)
- 7. Create a Server Endpoint (an API Route) in Astro
- Deploy to Cloudflare Workers
Generate a Firebase Service Account JSON
- Go to the Firebase Console and browse your project (create one if you do not have one already).
- In the Firebase Console, navigate to Project Settings by clicking the gear icon.
- Next, go to the Service accounts tab.
- Finally, click on Generate new private key and download the JSON file. This file contains your Firebase service account credentials.
Create a new Astro application
Let’s get started by creating a new Astro project. Open your terminal and run the following command:
npm create astro@latest my-app
npm create astro
is the recommended way to scaffold an Astro project quickly.
When prompted, choose:
Use minimal (empty) template
when prompted on how to start the new project.Yes
when prompted to install dependencies.Yes
when prompted to initialize a git repository.
Once that’s done, you can move into the project directory and start the app:
cd my-appnpm run dev
The app should be running on localhost:4321. Next, execute the command below to install the necessary library for building the application:
npm install jose
The following library is installed:
jose
: A library for JavaScript Object Signing and Encryption (JOSE) used to handle JWTs and other cryptographic operations.
Integrate Cloudflare adapter in your Astro project
To generate pre-signed URLs for each object dynamically, you will enable server-side rendering in your Astro project via the Cloudflare adapter. Execute the following command:
npx astro add cloudflare
When prompted, choose the following:
Y
when prompted whether to install the Cloudflare dependencies.Y
when prompted whether to make changes to Astro configuration file.
You have succesfully enabled server-side rendering in Astro.
To make sure that the output is deployable to Cloudflare Workers, create a wrangler.toml
file in the root of the project with the following code:
name = "firebase-storage-astro-workers"main = "dist/_worker.js"compatibility_date = "2025-04-01"compatibility_flags = [ "nodejs_compat" ]
[assets]directory="dist"binding="ASSETS"
[vars]FIREBASE_CLIENT_ID=""FIREBASE_PROJECT_ID=""FIREBASE_PRIVATE_KEY=""FIREBASE_CLIENT_EMAIL=""FIREBASE_STORAGE_BUCKET=""FIREBASE_PRIVATE_KEY_ID=""FIREBASE_CLIENT_X509_CERT_URL=""
Post that, make sure that you have both an .env
file and a wrangler.toml
file with the variables defined so that they can be accessed during npm run dev
and when deployed on Cloudflare Workers respectively.
Further, update the astro.config.mjs
file with the following to be able to access these variables in code programmatically:
// ... Existing imports...import { defineConfig, envField } from 'astro/config'
export default defineConfig({ env: { schema: { FIREBASE_PROJECT_ID: envField.string({ context: 'server', access: 'secret', optional: false }), FIREBASE_CLIENT_X509_CERT_URL: envField.string({ context: 'server', access: 'secret', optional: false }), FIREBASE_PRIVATE_KEY_ID: envField.string({ context: 'server', access: 'secret', optional: false }), FIREBASE_PRIVATE_KEY: envField.string({ context: 'server', access: 'secret', optional: false }), FIREBASE_CLIENT_ID: envField.string({ context: 'server', access: 'secret', optional: false }), FIREBASE_CLIENT_EMAIL: envField.string({ context: 'server', access: 'secret', optional: false }), FIREBASE_STORAGE_BUCKET: envField.string({ context: 'server', access: 'secret', optional: false }), } } // adapter})
Generate the pre-signed URLs
1. Access the Environment Variables
The first step is to access the necessary environment variables during the runtime to make fetch request to enable CORS on the bucket and be able to access the pre-signed URLs. From Astro 5.6 and beyond, the way you want to access runtime environment variables in your code is by using the getSecret
function from astro:env/server
to keep things provider agnostic. This is crucial for storing sensitive information securely without hardcoding it into your application. You’ll retrieve the following variables:
- FIREBASE_CLIENT_ID
- FIREBASE_PROJECT_ID
- FIREBASE_PRIVATE_KEY
- FIREBASE_CLIENT_EMAIL
- FIREBASE_STORAGE_BUCKET
- FIREBASE_PRIVATE_KEY_ID
- FIREBASE_CLIENT_X509_CERT_URL
import { getSecret } from 'astro:env/server'
const project_id = getSecret('FIREBASE_PROJECT_ID')const private_key_id = getSecret('FIREBASE_PRIVATE_KEY_ID')const private_key = getSecret('FIREBASE_PRIVATE_KEY')const client_email = getSecret('FIREBASE_CLIENT_EMAIL')const client_id = getSecret('FIREBASE_CLIENT_ID')const storageBucket = getSecret('FIREBASE_STORAGE_BUCKET')const client_x509_cert_url = getSecret('FIREBASE_CLIENT_X509_CERT_URL')
export default function getFirebaseConfig() { return { type: 'service_account', client_id, project_id, private_key, client_email, storageBucket, private_key_id, client_x509_cert_url, universe_domain: 'googleapis.com', token_uri: 'https://oauth2.googleapis.com/token', auth_uri: 'https://accounts.google.com/o/oauth2/auth', auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs', }}
2. Generate Google OAuth 2.0 Token
import getFirebaseConfig from './config'import { importPKCS8, SignJWT } from 'jose'
const serviceAccount = getFirebaseConfig()
async function getAccessTokenWithJose() { const privateKeyPem = serviceAccount.private_key const clientEmail = serviceAccount.client_email if (!privateKeyPem) throw new Error(`FIREBASE_PRIVATE_KEY environment variable is not available`) const iat = Math.floor(Date.now() / 1000) const exp = iat + 3600 const jwt = await new SignJWT({ iss: clientEmail, scope: 'https://www.googleapis.com/auth/devstorage.full_control', aud: 'https://oauth2.googleapis.com/token', iat, exp, }) .setProtectedHeader({ alg: 'RS256' }) .sign(await importPKCS8(privateKeyPem, 'RS256')) const res = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ assertion: jwt, grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', }), }) const data = await res.json() if (!res.ok) throw new Error(`Token error: ${data.error}`) return data.access_token}
The code above defines an asynchronous function getAccessTokenWithJose
that generates a JSON Web Token (JWT) using the jose
library. It signs the JWT with a private key obtained from Firebase configuration and then uses it to request an access token from Google’s OAuth 2.0 token endpoint. This access token is necessary for authenticating requests to Firebase Storage.
3. Set up Firebase Storage Bucket CORS Policy
// ...Existing imports...
// ...Existing code...
async function setBucketCORS(bucketName) { const accessToken = await getAccessTokenWithJose() const corsConfig = { cors: [ { origin: [ 'http://localhost:3000', 'https://your-deployment.workers.dev', ], maxAgeSeconds: 3600, method: ['GET', 'PUT', 'POST'], responseHeader: ['Content-Type', 'Content-MD5', 'Content-Disposition'], }, ], } const res = await fetch(`https://storage.googleapis.com/storage/v1/b/${bucketName.replace('gs://', '')}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify(corsConfig), }) if (!res.ok) { const err = await res.text() console.warn(`Failed to update CORS: ${res.statusText}\n${err}`) }}
The code above defines an asynchronous function named setBucketCORS
to configure Cross-Origin Resource Sharing (CORS) settings for a specified Firebase Storage bucket. It first retrieves an access token using the getAccessTokenWithJose
function. Then, it constructs a CORS configuration object that specifies allowed origins, HTTP methods, and response headers. The function sends a PATCH request to the Google Cloud Storage API to update the CORS settings of the bucket, using the access token for authorization.
4. Create Pre-signed URLs with Cloud Storage APIs
// ...Existing imports...import { createHash, createSign } from 'node:crypto'
async function generateSignedUrl(bucketName, objectName, { subresource = null, expiration = 604800, httpMethod = 'GET', queryParameters = {}, headers = {} } = {}) { if (expiration > 604800) throw new Error("Expiration can't be longer than 7 days (604800 seconds).") const privateKey = serviceAccount.private_key if (!privateKey) throw new Error(`FIREBASE_PRIVATE_KEY environment variable is not available`) const clientEmail = serviceAccount.client_email const now = new Date() const datestamp = now.toISOString().slice(0, 10).replace(/-/g, '') const timestamp = `${datestamp}T${now.toISOString().slice(11, 19).replace(/:/g, '')}Z` const credentialScope = `${datestamp}/auto/storage/goog4_request` const credential = `${clientEmail}/${credentialScope}` const host = `${bucketName.replace('gs://', '')}.storage.googleapis.com` headers['host'] = host // Canonical headers const sortedHeaders = Object.keys(headers) .sort() .reduce((obj, key) => { obj[key.toLowerCase()] = headers[key].toLowerCase() return obj }, {}) const canonicalHeaders = Object.entries(sortedHeaders) .map(([k, v]) => `${k}:${v}\n`) .join('') const signedHeaders = Object.keys(sortedHeaders).join(';') // Canonical query string const fullQueryParams: Record<string, any> = { ...queryParameters, 'X-Goog-Date': timestamp, 'X-Goog-Credential': credential, 'X-Goog-SignedHeaders': signedHeaders, 'X-Goog-Algorithm': 'GOOG4-RSA-SHA256', 'X-Goog-Expires': expiration.toString(), } if (subresource) fullQueryParams[subresource] = '' const orderedQueryParams = Object.keys(fullQueryParams) .sort() .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(fullQueryParams[key])}`) .join('&') const canonicalUri = `/${encodeURIComponent(objectName).replace(/%2F/g, '/')}` const canonicalRequest = [httpMethod, canonicalUri, orderedQueryParams, canonicalHeaders, signedHeaders, 'UNSIGNED-PAYLOAD'].join('\n') const hash = createHash('sha256').update(canonicalRequest).digest('hex') const stringToSign = ['GOOG4-RSA-SHA256', timestamp, credentialScope, hash].join('\n') const signature = createSign('RSA-SHA256').update(stringToSign).sign(privateKey).toString('hex') return `https://${host}${canonicalUri}?${orderedQueryParams}&X-Goog-Signature=${signature}`}
// ...Existing code...
The code above defines a generateSignedUrl
function that creates a signed URL for accessing objects in Firebase Storage. It uses cryptographic functions to generate a signature based on the request details, including the bucket and object names, HTTP method, and expiration time. The function constructs canonical headers and query parameters, then signs the request using the service account’s private key. The resulting signed URL allows secure access to the specified object in the storage bucket.
5. Pre-signed URL to GET a Firebase Object (retrieve)
The getFirebaseObject
function below retrieves an object’s pre-signed URL from Firebase Storage. It generates a signed request that allows you to access the file securely.
// ...Existing Code...
export async function getFirebaseObject(Key: string) { try { await setBucketCORS(serviceAccount.storageBucket) return await generateSignedUrl(serviceAccount.storageBucket, Key, { httpMethod: 'GET', expiration: 60 * 60 * 24, }) } catch (e: any) { const tmp = e.message || e.toString() console.log(tmp) return }}
6. Pre-signed URL to PUT a Firebase Object (upload)
The uploadFirebaseObject
function below is responsible for generating a pre-signed URL for uploading a file to Firebase Storage. It follows a similar structure to the getFirebaseObject
function, generating a signed URL that allows you to upload files securely.
// ...Existing Code...
export async function uploadFirebaseObject(file: { name: string; type: string }) { try { await setBucketCORS(serviceAccount.storageBucket) return await generateSignedUrl(serviceAccount.storageBucket, file.name, { httpMethod: 'PUT', expiration: 60 * 60 * 24, headers: { 'Content-Type': file.type, }, }) } catch (e: any) { const tmp = e.message || e.toString() console.log(tmp) return }}
7. Create a Server Endpoint (an API Route) in Astro
import type { APIContext } from 'astro'import { getFirebaseObject, uploadFirebaseObject } from '../../storage/firebase/index'
// Define an asynchronous function named GET that accepts a request object.export async function GET({ request }: APIContext) { // Extract the 'file' parameter from the request URL. const url = new URL(request.url) const file = url.searchParams.get('file') // Check if the 'file' parameter exists in the URL. if (file) { try { const filePublicURL = await getFirebaseObject(file) // Return a response with the image's public URL and a 200 status code. return new Response(filePublicURL) } catch (error: any) { // If an error occurs, log the error message and return a response with a 500 status code. const message = error.message || error.toString() console.log(message) return new Response(message, { status: 500 }) } } // If the 'file' parameter is not found in the URL, return a response with a 400 status code. return new Response('Invalid Request.', { status: 400 })}
export async function POST({ request }: APIContext) { // Extract the 'file' parameter from the request URL. const url = new URL(request.url) const type = url.searchParams.get('type') const name = url.searchParams.get('name') if (!type || !name) return new Response('Invalid Request.', {status:400}) try { // Generate an accessible URL for the uploaded file // Use this url to perform a GET to this endpoint with file query param valued as below const publicUploadUrl = await uploadFirebaseObject({ type, name }) // Return a success response with a message return new Response(publicUploadUrl) } catch (error: any) { // If there was an error during the upload process, return a 403 response with the error message const message = error.message || error.toString() console.log(message) return new Response(message, { status: 500 }) }}
Deploy to Cloudflare Workers
To make your application deployable to Cloudflare Workers, create a file named .assetsignore
in the public
directory with the following content:
_routes.json_worker.js
Next, you will need to use the Wrangler CLI to deploy your application to Cloudflare Workers. Run the following command to deploy:
npm run build && npx wrangler@latest deploy
References
Conclusion
In this blog post, you learned how to integrate Firebase Storage with Astro and Cloudflare Workers for file uploads and retrieval. By following the implementation steps, you can securely upload and retrieve files from Firebase Storage, ensuring that your web application has a robust and flexible storage solution.
If you would like to explore specific sections in more detail, expand on certain concepts, or cover additional related topics, please let me know, and I’ll be happy to assist!