ScreenshotNeo

BlogHow-to

How to Debug a 403 Error When Taking Screenshots of a Logged-In Website

A 403 means the server refused a request, but it does not prove your login failed. Trace the exact request, verify Playwright's browser state, and check access permissions.

By the ScreenshotNeo team4 October 20269 min read

A 403 error when taking screenshots of a logged-in website means that the server understood a particular request and refused to fulfill it. It does not by itself prove that login failed. First identify which request returned 403; then verify authentication in the exact browser context used for capture; then check whether that account is allowed to access the requested resource.

The browser may have loaded the page successfully while an API call or embedded image returned 403. Or the top-level document may have been refused. A screenshot alone cannot distinguish these cases. Without the target site’s response and a network trace, no single root cause can be established. The HTTP definition and the Playwright procedures below follow RFC 9110, section 15.5.4 and the Playwright authentication guide.

1. Find the exact request that returned 403

Record the failing request’s URL, method, status, redirect chain, response body, and relevant request and response headers. Redact cookies, authorization values, tokens, and personal data before sharing logs or traces.

  1. Open the page in the browser or automation run that reproduces the problem.
  2. Inspect the Network panel, or capture a Playwright trace, and locate each 403 response around navigation and screenshot time.
  3. Classify the request: top-level document, redirect, API call, or embedded resource such as an image, script, or stylesheet.
  4. Record the final page URL and preserve the response body and relevant headers securely.

A 403 from the document means something different diagnostically from a 403 on one image after the page loaded. Compare the exact failing URL with a known permitted page and note whether only a particular tenant, role, or resource is affected.

Playwright’s page.goto() returns a response for HTTP error status codes; a 403 does not necessarily make the call throw. The response can also be null for some navigation cases, so guard it:

const response = await page.goto(targetUrl);
console.log({
  url: response?.url(),
  status: response?.status(),
  ok: response?.ok(),
});

Here, ok is false for a 403. This check reports the navigation response only; it does not report every failed subrequest. Use the browser’s network tools or a Playwright trace to inspect those.

2. Check login in the capture browser context

Your ordinary browser profile and an automation browser context are separate. Being signed in interactively does not mean the Playwright context has the same cookies, local storage, or other authentication state.

Use the authorized login flow in the same context that will take the screenshot. After submitting credentials, wait for a stable signal that login completed: the expected final URL or a visible element that only appears for signed-in users. Login redirects may set cookies along the way, so do not save state before the flow has finished.

import { chromium } from 'playwright';

const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();

await page.goto('https://example.com/login');
// Fill the site's login form through its authorized flow.
// Replace these selectors with the site's actual form selectors.
await page.locator('[name="username"]').fill(process.env.TEST_USERNAME);
await page.locator('[name="password"]').fill(process.env.TEST_PASSWORD);
await page.locator('button[type="submit"]').click();

// Prefer a stable post-login signal for the application.
await page.waitForURL('**/account');
await page.getByRole('button', { name: 'Account menu' }).waitFor();

await context.storageState({ path: 'playwright/.auth/state.json' });
await browser.close();

Replace the URL pattern and signed-in element with signals that match the target application. Keep credentials in your secret store or environment, not in source code. If a URL is not a reliable signal, wait for a stable signed-in element instead.

To reuse saved state for a capture, load it into the context before creating the page:

import { chromium } from 'playwright';

const browser = await chromium.launch();
const context = await browser.newContext({
  storageState: 'playwright/.auth/state.json',
});
const page = await context.newPage();
const response = await page.goto('https://example.com/account');

console.log({
  url: response?.url(),
  status: response?.status(),
  ok: response?.ok(),
});

await page.screenshot({ path: 'account.png', fullPage: true });
await browser.close();

Playwright’s standard storage state covers cookies and local storage. It can include IndexedDB when the application stores auth there and the installed Playwright version supports the relevant option. Session storage is not included in the standard persistence API; if the application depends on it, follow the documented save-and-restore technique. Check the documentation for your installed version before relying on a version-specific option.

When state appears present but authentication still fails, check that cookies match the destination’s domain and path and have not expired. Secure-cookie settings also matter: a cookie restricted to HTTPS will not be sent on an HTTP URL. Regenerate expired state by completing the authorized login flow again.

Protect auth-state files. Saved cookies and tokens can let someone impersonate the account. Exclude state files from version control, avoid putting them in shared traces or logs, and use a least-privileged test account.

3. Separate authentication from authorization

A valid session does not guarantee permission to every page. RFC 9110 defines 403 as a refusal and says a request can be forbidden for reasons unrelated to credentials. So if the capture context is signed in, investigate access to the particular URL before changing login code.

  1. Using the same context and account, request a harmless page known to be available.
  2. If that page works, compare the failing resource’s role, tenant, entitlement, and resource-specific permissions.
  3. If the known permitted page also fails, recheck whether the saved session is valid and whether the login flow completed.
  4. Ask the site administrator whether the account, network origin, or automation workflow is allowed to request that resource.

Keep the comparison controlled: use the same account, target environment, network, browser context, and approximate workflow. Otherwise, a difference between runs may have more than one explanation.

4. Use a trace to make the failure reproducible

A Playwright trace can show actions, page snapshots, logs, and network requests around the failure. Run a minimal reproduction with tracing enabled, then inspect it with Playwright’s Trace Viewer.

import { chromium } from 'playwright';

const browser = await chromium.launch();
const context = await browser.newContext({
  storageState: 'playwright/.auth/state.json',
});
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });

const page = await context.newPage();
const response = await page.goto('https://example.com/account');
console.log('Navigation response:', response?.status(), response?.url());
await page.screenshot({ path: 'account.png', fullPage: true });

await context.tracing.stop({ path: 'trace.zip' });
await browser.close();

Limit the trace to a minimal reproduction. Treat it as sensitive: redact account identifiers, cookies, bearer tokens, page contents, and personal data before sharing. A trace can help locate a refusal; it cannot establish the site’s policy or grant access.

5. Compare evidence before choosing a fix

Evidence What to check next
Top-level document returns 403 Check final URL, redirect chain, response body, browser-context login state, and account permission for that page.
Document loads, API request returns 403 Inspect that API request’s URL and method and whether the app’s session or required state is present in the capture context.
Document loads, one asset returns 403 Confirm whether the asset is restricted or essential to the screenshot; do not treat it as proof that the whole page is unauthenticated.
Every page fails in automation Check whether the context completed login, whether saved state is expired, and whether the account or automation origin is allowed.
Only one resource or tenant fails Check resource-specific permissions, account role, tenant, and entitlement with the site administrator.

A bare screenshot that displays “Forbidden” is not enough to identify the cause. Use the status, request scope, response evidence, and context comparison together.

Common errors and fixes

Symptom Likely explanation to investigate Next step
page.goto() did not throw, but navigation is forbidden HTTP error responses can be returned as navigation responses. Check response?.status() and inspect the response in network tools.
Navigation response is null Some special navigation cases have no response. Keep the null guard and inspect the trace and network activity for the actual request.
Interactive browser is signed in, automation is not The automation context has separate storage. Complete the authorized login in that context or load valid storage state.
Saved state loads, but the site redirects to login State may have expired, may have been saved before login finished, or may rely on storage not included in the saved state. Finish login before saving; check expiry and whether the app uses IndexedDB or sessionStorage; regenerate state as needed.
One protected URL fails while a control page works The account may lack access to that role, tenant, or resource. Verify permissions through the site’s authorized process or administrator.
403 response body is generic The response alone may not explain the site’s reason for refusal. Preserve the status, headers, redirect sequence, and trace; ask the site owner to interpret policy.

Do not automatically retry the identical request with the same credentials. RFC 9110 advises against automatically repeating a 403 request with those credentials. Diagnose from evidence and use authorized next steps instead. Do not use user-agent spoofing, CAPTCHA bypass, cookie theft, or access-control evasion to work around a refusal.

Performance, reliability, and cost considerations

For repeatable captures, reuse a valid context or storage state rather than adding an unnecessary login flow to every screenshot. Still confirm that authentication has completed and that the state is current; stale state can make a run fail in a way that resembles a permission problem. Use a stable post-login signal and the smallest trace that captures the failure.

Network traces and screenshots can contain sensitive page data and credentials, so store them with the same care as auth state and remove them when they are no longer needed. A 403 is a server response, not evidence of a slow page or a transient failure; repeated identical retries add work without resolving a refusal. No frequency, success rate, or universal cause can be inferred without evidence from the target site.

Or skip the browser setup

If you need a screenshot without building and maintaining a browser capture flow, ScreenshotNeo is a website screenshot API and MCP server. For a public page, one GET request returns an image or PDF. Its cleanup steps accept consent banners like a visitor and remove 60+ known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. A 403 from a protected site still needs an authorized access path.

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

See the ScreenshotNeo API documentation for the request options. Equivalent Python:

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
open("shot.webp", "wb").write(r.content)

And Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed; response headers identify the page verdict and whether the request 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 a month without a card; paid plans start at $5 for 3,000 shots. Sign up free for 1,000 screenshots a month, with no card required.

FAQ

Why am I getting a 403 when logged in?

The session may not be present or valid in the browser context making the request, or the account may be signed in but not allowed to access that resource. Inspect the failing request and test a known permitted page in the same context.

Does a 403 mean the password is wrong?

No. A 403 indicates refusal to fulfill the request; it does not by itself identify an incorrect password or failed login.

Why does the page look fine even though the capture has a 403?

A subrequest such as an API call or image can return 403 while the top-level document succeeds. Identify which request failed before diagnosing the page.

Can I use my normal browser cookies in Playwright?

Use an authorized, deliberately managed auth-state flow for the automation context. Do not copy or share live credentials casually; state files can grant account access.

Should I keep retrying the same capture?

No. First inspect the response and context. The HTTP specification advises against automatically repeating a 403 with the same credentials.