ScreenshotNeo

BlogHow-to

How to Authenticate a Firebase User in Puppeteer With a JWT

Mint a Firebase custom token on a trusted server, sign in through the web SDK in Puppeteer, and keep automated sessions isolated.

By the ScreenshotNeo team1 October 20269 min read

Direct answer: create a Firebase custom token on a trusted server with the Firebase Admin SDK, pass that short-lived token to your Puppeteer test, and call the page’s Firebase Web SDK signInWithCustomToken(auth, token). Keep the service-account key on the server, never in Puppeteer or browser code. Use a separate Puppeteer BrowserContext for each identity that must not share cookies or local storage.

A Firebase custom token starts the client sign-in exchange. It is not the same thing as the Firebase ID token that the client receives afterward and sends to your backend. The custom token is signed server-side; the web SDK exchanges it for the normal Firebase session.

How the authentication flow works

  1. A trusted test helper or application backend authenticates the test identity and uses the Firebase Admin SDK to mint a custom token for a UID.
  2. Puppeteer opens your application in a page where Firebase has already been initialized.
  3. The test passes the custom token into the page through a controlled mechanism.
  4. Code running in the page calls signInWithCustomToken and awaits its Promise.
  5. Your test confirms the authenticated state through the application’s UI or application logic.
  6. If your backend needs the user identity, the client obtains a Firebase ID token and sends it to the backend, which verifies it with Firebase Admin Auth.

Firebase documents custom-token creation as a server-side operation. The Admin SDK-created token expires after one hour, and a manually signed token may not have an expiration more than 3,600 seconds after issuance. The resulting client session can remain signed in until the user signs out or the session is invalidated.

Prerequisites and project setup

  • A Firebase project with the Authentication provider configuration required by your application.
  • A test identity or UID that your application recognizes.
  • A trusted Node.js process with the Firebase Admin SDK and access to service-account credentials.
  • Puppeteer installed in the test project.
  • A page architecture that exposes its initialized Firebase Auth instance to test code, or a test-only bridge that can call the SDK inside the page.

Do not commit a service-account JSON file. Its private key can mint credentials for your project. Load it through your deployment secret store or an environment variable that is available only to the trusted server process.

Mint a Firebase custom token on a trusted server

The following helper uses the Firebase Admin SDK. It accepts a UID and optional custom claims, then prints a token for a test runner or an internal endpoint to consume.

import { cert, getApps, initializeApp } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';

if (getApps().length === 0) {
  initializeApp({
    credential: cert({
      projectId: process.env.FIREBASE_PROJECT_ID,
      clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
      privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n')
    })
  });
}

export async function createTestCustomToken(uid) {
  if (!uid || uid.length < 1 || uid.length > 128) {
    throw new Error('Firebase UIDs must be 1 to 128 characters');
  }

  return getAuth().createCustomToken(uid, {
    testUser: true
  });
}

if (process.argv[1] === new URL(import.meta.url).pathname) {
  const uid = process.argv[2] || 'puppeteer-test-user';
  console.log(await createTestCustomToken(uid));
}

Run this only in a trusted environment:

FIREBASE_PROJECT_ID='your-project-id' \
FIREBASE_CLIENT_EMAIL='firebase-adminsdk-...@your-project-id.iam.gserviceaccount.com' \
FIREBASE_PRIVATE_KEY='-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n' \
node mint-token.js test-user-1

For a real test suite, expose a protected internal endpoint or test fixture that returns the token to the runner. Authenticate that request and keep the endpoint unavailable to untrusted users.

Sign in inside Puppeteer with page.evaluate

Puppeteer’s page.evaluate executes in the page context and waits for a returned Promise. That makes it suitable for invoking the Firebase Web SDK after your application has initialized Firebase Auth.

This example assumes the application deliberately exposes its initialized Auth instance as window.firebaseAuth and the SDK function as window.signInWithCustomToken in the test environment. The exact export depends on your bundler and Firebase SDK version.

import puppeteer from 'puppeteer';

const appUrl = 'https://example.test/login';
const customToken = await getCustomTokenFromTrustedTestBackend();

const browser = await puppeteer.launch({ headless: true });
const context = await browser.createBrowserContext();
const page = await context.newPage();

try {
  await page.goto(appUrl, { waitUntil: 'networkidle2' });

  await page.waitForFunction(() => {
    return Boolean(window.firebaseAuth && window.signInWithCustomToken);
  });

  await page.evaluate(async (token) => {
    await window.signInWithCustomToken(window.firebaseAuth, token);
  }, customToken);

  await page.waitForSelector('[data-authenticated="true"]');
  console.log('Firebase user is signed in');
} finally {
  await context.close();
  await browser.close();
}

async function getCustomTokenFromTrustedTestBackend() {
  // Call your protected test helper here. Do not sign or mint the token in this file.
  const response = await fetch('https://test-helper.example.internal/firebase-token', {
    headers: { authorization: `Bearer ${process.env.TEST_HELPER_TOKEN}` }
  });
  if (!response.ok) throw new Error(`Token helper returned ${response.status}`);
  const body = await response.json();
  return body.customToken;
}

If your application uses the modular Firebase SDK, your page bridge may look like this in application code:

import { getAuth, signInWithCustomToken } from 'firebase/auth';
import { app } from './firebase-app.js';

const auth = getAuth(app);

if (import.meta.env.MODE === 'test') {
  window.firebaseAuth = auth;
  window.signInWithCustomToken = signInWithCustomToken;
}

Expose this bridge only in a controlled test build or behind a test-only guard. Never add a service-account key or Admin SDK import to browser code.

Keep test users isolated

Create a new browser context when each test identity must have independent cookies and local storage. Closing the context disposes of its pages.

const browser = await puppeteer.launch();

const aliceContext = await browser.createBrowserContext();
const bobContext = await browser.createBrowserContext();

const alicePage = await aliceContext.newPage();
const bobPage = await bobContext.newPage();

// Sign Alice into alicePage and Bob into bobPage with separate custom tokens.

await aliceContext.close();
await bobContext.close();
await browser.close();

Puppeteer also supports a userDataDir launch option when deliberately reusing a browser profile. That is a different choice: it preserves profile state between runs instead of isolating each identity. Use it only when persistent state is part of the test.

Custom token versus ID token

Token Created by Purpose Where it belongs
Custom token Firebase Admin SDK or a trusted signer Begins client sign-in through signInWithCustomToken Trusted server creates it; browser receives it briefly
ID token Firebase Authentication after sign-in Proves the signed-in user to your backend Client sends it to a backend that verifies it with Admin Auth

Do not pass an ID token to signInWithCustomToken. Do not treat a custom token as the credential your backend should verify for normal API authorization. For privileged server-side access to Realtime Database and other Firebase services, use the Admin SDK rather than minting a custom token for the server.

Manual JWT signing constraints

The Admin SDK is the safer implementation because it handles the Firebase custom-token format. If you manually sign a token, Firebase documents an RS256 JWT with the service-account email as issuer and subject, the Identity Toolkit audience, issued-at and expiration claims, and a UID. The UID must be 1–128 characters, and expiration cannot be more than 3,600 seconds after issuance. A malformed audience, issuer, subject, timestamp, signature, or UID causes sign-in to fail.

Waiting for the application to finish signing in

Awaiting signInWithCustomToken confirms that Firebase accepted the credential. It does not guarantee that your application’s route transition, profile fetch, or UI update has completed. Wait for an application-specific signal:

await page.evaluate(async (token) => {
  await window.signInWithCustomToken(window.firebaseAuth, token);
}, customToken);

await page.waitForFunction(() => {
  return document.body.dataset.authState === 'signed-in';
});

A data attribute, authenticated-only selector, or test endpoint is more reliable than a fixed sleep. If the application redirects after sign-in, wait for the expected URL and then for the page’s authenticated content.

Common errors and fixes

Error or symptom Likely cause Fix
auth/invalid-custom-token The token is malformed, signed with the wrong key, has the wrong audience, or is not a Firebase custom token. Generate it with the Admin SDK, confirm the Firebase project, and keep the private key and project configuration together.
auth/expired-custom-token The token passed its expiration time or the machine clock is incorrect. Mint immediately before the test, check clock synchronization, and keep expiration within Firebase’s documented limit.
Passing an ID token to signInWithCustomToken fails Custom tokens and ID tokens serve different steps. Pass the server-minted custom token to the web SDK; verify the resulting ID token on your backend.
window.firebaseAuth is undefined The page has not initialized Firebase or does not expose a test bridge. Wait for initialization, expose the initialized Auth instance in a test-only build, or invoke a test hook that lives in the application.
Sign-in resolves but the UI still shows logged out Application state or profile loading is asynchronous. Wait for an auth-state callback, route change, or authenticated selector rather than using a fixed delay.
One test sees another user’s account Pages share a browser context or a reused profile. Create one BrowserContext per identity and close it after the test. Avoid a shared userDataDir unless persistence is intentional.
Credentials appear in source control or browser logs A service-account key or full token was placed in client code or verbose logs. Keep signing on the trusted server, redact tokens, rotate exposed keys, and remove secrets from repository history.

Reliability and performance considerations

  • Mint late: create the custom token immediately before the browser needs it so network retries do not consume most of its lifetime.
  • Reuse the browser carefully: reusing one browser process can reduce startup overhead, but keep identities in separate contexts.
  • Prefer state signals: wait for Firebase initialization and application-authenticated selectors instead of arbitrary sleeps.
  • Retry the right operation: a transient failure from your token helper can be retried; do not blindly retry an invalid token without creating a new one.
  • Keep tests deterministic: use dedicated test UIDs and claims, and reset server-side test data between runs.
  • Capture diagnostics safely: record status codes and error classes, but do not print service-account JSON, private keys, custom tokens, or ID tokens.

Or skip the browser setup

If your goal is a clean screenshot of the authenticated result rather than browser-authentication plumbing, ScreenshotNeo provides a website screenshot API. You still perform Firebase sign-in in your application or test environment, then capture the resulting page with one request. See the ScreenshotNeo API documentation for the available parameters.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.test/account -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://example.test/account"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.test/account' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', image));

ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and whether the shot was billed. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

FAQ

How do I sign in with a Firebase custom token?

Mint it with Firebase Admin Auth on a trusted server, then call signInWithCustomToken(auth, token) in the page’s Firebase Web SDK and await the returned Promise.

Cookie manipulation is not a substitute for the documented Firebase client sign-in flow. Use the SDK exchange so the application receives its normal authenticated state.

Does the custom token need to be refreshed every hour?

The custom token itself is short-lived. After a successful exchange, Firebase maintains the client session according to its normal session behavior; token expiration does not immediately log out that established session.

Should I use the Admin SDK from a browser test?

No. Use the Admin SDK only in a trusted helper or backend. The browser test should receive a custom token through a controlled channel and pass it to the client SDK.

How do I prevent authentication state leaking between tests?

Create separate Puppeteer BrowserContexts for separate identities and close each context when its test ends. Use a persistent userDataDir only when shared profile state is intentional.

Checklist

  • Custom tokens are minted only on a trusted server.
  • Service-account credentials are outside source control and browser bundles.
  • Puppeteer calls the Firebase Web SDK’s signInWithCustomToken.
  • Tests distinguish custom tokens from ID tokens.
  • Each isolated identity gets its own BrowserContext.
  • Tests wait for application-authenticated state, not just token resolution.
  • Backend APIs verify Firebase ID tokens with Admin Auth.