ScreenshotNeo

BlogHow-to

How to Protect a Screenshot API Key in a Frontend App

A browser app cannot keep a shared screenshot API key secret. Put it behind a server endpoint that authenticates callers, validates requests, and controls usage.

By the ScreenshotNeo team4 October 202611 min read

A screenshot API key cannot be kept secret in a frontend app if the browser sends it or receives it. Anything delivered to the browser—including JavaScript bundles, HTML, browser storage, and client-visible configuration—can be read or changed by the user. Keep the provider key in server-side secret storage, have the frontend call an endpoint you control, and let that endpoint authenticate and constrain each request before calling the screenshot service.

This pattern is often called a backend-for-frontend (BFF) or a proxy endpoint. The endpoint is also your authorization and cost-control boundary. Hiding a button, checking permissions in client-side JavaScript, or restricting CORS does not protect a shared provider key.

Why frontend API keys are exposed

When a browser app makes a direct request to a screenshot provider, the user can inspect the request in developer tools and see its URL, headers, and body. They can also download and search the JavaScript bundle, inspect HTML and client-visible configuration, and modify the app’s code while it runs. Obfuscation and minification do not change this.

Build-time environment variables are not automatically secret. If the framework substitutes a variable into browser code, it becomes part of the delivered app. Do not place a provider key in a public-prefixed variable or serialize it into page data. OWASP’s [Web Frontend Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Frontend_Security_Cheat_Sheet.html) summarizes the rule: “Anything sent to the client can be read or modified by the user.”

  1. Browser: sends only the screenshot inputs your app permits to your own endpoint. It does not know the screenshot provider key.
  2. Your server: authenticates and authorizes the caller, validates and constrains inputs, applies quotas and rate limits, and reads the key from server-side secret storage.
  3. Screenshot provider: receives a server-to-server request containing the key in its supported authentication mechanism.
  4. Your server: returns the permitted image or PDF, or a controlled error, to the browser.

Keep the endpoint narrow: for example, allow a screenshot of a validated URL with bounded viewport dimensions and an allowed output format. Do not make it a generic open proxy that forwards arbitrary URLs, headers, provider parameters, or operations supplied by callers.

Build a server-side screenshot route

The example below uses Node.js with Express and a provider that accepts a key and screenshot options. Replace the provider URL, authentication mechanism, and parameter names with those documented by your provider. This illustrative route returns an image response; adapt content types and error handling if your provider returns JSON or supports PDFs.

1. Store the key on the server

Configure SCREENSHOT_API_KEY in your deployment platform’s server-side secret configuration or a secrets vault. Do not use a variable intended for public client bundles. OWASP recommends protecting application secrets and using a secrets vault where appropriate in its [Developer Guide](https://devguide.owasp.org/en/04-design/02-web-app-checklist/07-protect-data/).

2. Install dependencies

npm install express

Use a current supported Node.js runtime, and configure your deployment to provide the secret as an environment variable. The sample assumes Node.js with built-in fetch.

3. Create the endpoint

import express from 'express';

const app = express();
app.use(express.json({ limit: '16kb' }));

const apiKey = process.env.SCREENSHOT_API_KEY;
if (!apiKey) throw new Error('SCREENSHOT_API_KEY is not configured');

const allowedHosts = new Set(['example.com', 'www.example.com']);

function parseAllowedUrl(value) {
  let target;
  try {
    target = new URL(value);
  } catch {
    return null;
  }
  if (target.protocol !== 'https:') return null;
  if (!allowedHosts.has(target.hostname)) return null;
  if (target.username || target.password) return null;
  return target;
}

// Replace this with your real session or token verification.
async function requireUser(req, res, next) {
  // Example integration point: verify the session, then set req.user.
  // Do not trust a user ID or role supplied in the request body.
  if (!req.user) return res.status(401).json({ error: 'Authentication required' });
  next();
}

app.post('/api/screenshot', requireUser, async (req, res) => {
  const target = parseAllowedUrl(req.body?.url);
  if (!target) {
    return res.status(400).json({ error: 'A permitted HTTPS URL is required' });
  }

  const width = Number(req.body?.width ?? 1280);
  const height = Number(req.body?.height ?? 800);
  if (!Number.isInteger(width) || width < 320 || width > 1920 ||
      !Number.isInteger(height) || height < 240 || height > 1920) {
    return res.status(400).json({ error: 'Viewport dimensions are out of range' });
  }

  // Apply a per-user quota and rate limit here, before spending provider quota.
  // Example: await enforceQuota(req.user.id);

  const providerParams = new URLSearchParams({
    url: target.href,
    width: String(width),
    height: String(height),
    format: 'png'
  });

  let upstream;
  try {
    upstream = await fetch(`https://provider.example/screenshot?${providerParams}`, {
      method: 'GET',
      headers: { Authorization: `Bearer ${apiKey}` },
      signal: AbortSignal.timeout(60_000)
    });
  } catch {
    return res.status(502).json({ error: 'Screenshot provider could not be reached' });
  }

  if (!upstream.ok) {
    // Log a request ID and status internally; do not log the secret or return raw upstream details.
    return res.status(502).json({ error: 'Screenshot provider returned an error' });
  }

  const contentType = upstream.headers.get('content-type') || '';
  if (!contentType.startsWith('image/')) {
    return res.status(502).json({ error: 'Screenshot provider returned an unexpected response' });
  }

  res.set('Content-Type', contentType);
  res.set('Cache-Control', 'private, no-store');
  const bytes = Buffer.from(await upstream.arrayBuffer());
  return res.status(200).send(bytes);
});

app.listen(process.env.PORT || 3000);

The authentication middleware is intentionally an integration point: connect it to your existing session or token system. If the route is meant to be public, it still needs abuse controls, quotas, and input constraints; public access does not make the upstream key safe to expose.

4. Call your route from the browser

async function requestScreenshot(url) {
  const response = await fetch('/api/screenshot', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'same-origin',
    body: JSON.stringify({ url, width: 1280, height: 800 })
  });

  if (!response.ok) {
    const error = await response.json().catch(() => ({}));
    throw new Error(error.error || `Screenshot request failed (${response.status})`);
  }

  return URL.createObjectURL(await response.blob());
}

For a cookie-authenticated app, use your framework’s CSRF protections for state-changing requests. If the browser sends a bearer token, validate it on the server and check that the authenticated user is authorized for the requested operation.

Validate inputs and control the endpoint

Every input that affects a capture can affect security, provider usage, or response size. Define a small allowlist based on what the app actually needs.

Input or control Server-side rule Why it matters
Target URL Require HTTPS; parse it; reject credentials and unsupported schemes; allow only the domains your product needs when practical. Prevents the endpoint from becoming an arbitrary capture service or a path to internal network resources. Consider DNS rebinding and private IP destinations if users may submit arbitrary hosts.
Viewport and scale Set minimum and maximum width, height, and device scale. Reject non-integers and unexpected values. Limits expensive or very large captures and response payloads.
Format and output Allow only supported image types or PDF if the app needs it. Set maximum output and execution limits where supported. Prevents callers from selecting unneeded operations or oversized output.
Provider options Map accepted fields explicitly. Do not spread the request body into provider parameters. Stops callers from enabling arbitrary headers, cookies, scripts, or other powerful options.
Identity and quota Authenticate where required; derive user or tenant identity from verified credentials; enforce per-user limits. A frontend-supplied identity or role can be changed by the caller.
Request frequency Rate-limit by user and, where useful, by IP. Return HTTP 429 when the limit is exceeded. Controls abuse and cost. See OWASP’s [REST Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html).

For arbitrary-URL capture products, also consider server-side request forgery (SSRF): block loopback, link-local, private, and reserved address ranges; re-check resolved addresses and redirects; and apply network egress controls. A hostname allowlist is often safer for an app that only needs to capture a known set of sites. The exact policy depends on the app and provider.

Keep credentials out of URLs and logs

Send the provider credential using its documented authentication header or other supported server-to-server mechanism. Avoid query-string credentials: URLs are commonly recorded in access logs and other diagnostic systems. OWASP discusses this exposure in its [REST Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html).

Also avoid logging authorization headers, environment values, full request objects, or provider URLs if they contain sensitive data. Return a stable, generic error to the browser and keep diagnostic details in access-controlled server logs. Never include the key in HTML, API responses, source maps, telemetry, or exception messages.

CORS, public keys, and frontend configuration

  • CORS is not a secret store. It controls which browser origins may read cross-origin responses under browser rules; it does not stop a user from copying a key or making requests with another client. Keep the key server-side and enforce authorization at the endpoint. See OWASP’s [CORS guidance](https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html).
  • Client environment variables are public if bundled. Framework naming conventions differ, but any variable compiled into browser code is visible. Keep the secret in the server runtime and ensure it is not serialized into page props or server-rendered data sent to the client.
  • A provider-supported public credential is a different case. If a provider offers a restricted browser key designed for public use, verify the current provider documentation for its restrictions and limits. Do not assume a normal API key becomes safe because it is called “public.”
  • Client-only apps need a different integration model. Introduce a trusted server-side component, use a provider-supported public credential if it truly meets the use case, or choose an integration that does not require a shared secret in the browser.

If the key has already shipped

  1. Revoke or rotate the exposed key at the provider. Removing it from the current source does not make copies already delivered or committed private again.
  2. Check provider usage and billing records for requests you do not recognize.
  3. Move the replacement into server-side secret storage and route requests through the constrained endpoint.
  4. Review repositories, build artifacts, source maps, logs, and deployment configuration for other copies; remove them where possible.
  5. Add monitoring and alerts for unusual volume, errors, and quota consumption.

OWASP recommends revoking compromised or misused keys and applying throttling to APIs. Treat exposure as a credential incident rather than relying only on a source cleanup.

Common errors and fixes

Symptom Likely cause Fix
The key appears in DevTools or the downloaded bundle. The frontend calls the provider directly or a build-time variable was embedded in client code. Remove the direct call, rotate the key, and call a server endpoint that reads a server-side secret.
The server says the key is missing. The deployment secret is absent, named differently, or only configured for a different environment. Set the secret in the server runtime for the deployed environment and restart or redeploy as required.
The provider returns 401 or 403. The key is invalid, revoked, sent using the wrong authentication scheme, or lacks permission. Check provider authentication documentation and server configuration. Do not send the key to the browser to diagnose it.
Browser request fails with a CORS error. The browser is calling a different origin without an appropriate policy, or the endpoint’s CORS policy is wrong. Prefer a same-origin route; otherwise allow only the required origins. Do not treat a permissive CORS policy as authorization.
Requests work but costs spike. The endpoint is publicly abusable, callers can choose expensive options, or there are no quotas or rate limits. Authenticate as appropriate, constrain inputs, add per-user and IP limits, set usage alerts, and inspect logs.
Captures fail for private or redirected URLs. Your URL validation, network policy, DNS resolution, or redirect handling rejects them—or is too permissive. Define the intended URL policy, validate redirects and resolved addresses, and test both allowed and denied cases.
Large responses consume memory or time out. The endpoint buffers unbounded output or permits huge captures. Bound dimensions and timeouts, enforce response size limits, and stream the upstream response if your framework and provider support safe streaming.

Performance, reliability, and cost

A proxy adds a network hop and server work. Keep the route close to your users or deployment region where practical, set a bounded upstream timeout, and avoid retrying captures blindly: a timed-out request may still have consumed provider resources. If you retry, use a small retry budget and only when the provider’s behavior makes retries safe.

Apply limits before contacting the provider, and record request IDs, caller identity, response status, duration, and usage without recording secrets. Per-user quotas help connect usage to an account; global limits protect the application if a credential or endpoint is abused. Cache only when the screenshot’s freshness, access rules, and privacy requirements permit it. Ensure cached results cannot cross user or tenant boundaries accidentally.

Operational burden depends on whether your team already runs a backend or serverless environment and secret store. A managed BFF can reduce infrastructure work, but it does not remove the need to validate requests, authorize callers, set usage limits, and monitor the billable operation.

Or skip the browser setup

ScreenshotNeo is a screenshot API and MCP server. Keep its API key on your server and call it from the endpoint you control. The API accepts one GET request for a URL and returns a PNG, JPEG, WebP, or PDF. See the ScreenshotNeo API documentation for request options.

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

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed (${res.status})`);
await Bun.write('shot.webp', res);

Store YOUR_API_KEY server-side; these examples are for server-to-server calls. ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents use screenshot tools. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000. Sign up for 1,000 free screenshots a month with no card.

FAQ

Can I hide a key by encrypting or obfuscating the JavaScript?

No. The browser must be able to run the code and make the request, so a user can inspect or alter it. Keep a shared secret on a trusted server.

No. Browser storage and client-readable cookies are accessible to the user and may also be exposed by client-side vulnerabilities. They are not a substitute for server-side secret storage.

Does using a serverless function count as a backend?

Yes, if it runs in a trusted server environment, keeps the provider key out of the response, and enforces the same validation, authorization, and usage controls as any other backend route.

Can I allow any visitor to request screenshots?

You can expose a public endpoint, but then anyone can call it. Restrict capture inputs and volume, rate-limit, monitor usage, and decide what cost you are willing to accept.