ScreenshotNeo

BlogHow-to

How to Capture Authenticated Web Pages with PHP cURL

Learn how to log in with PHP cURL, preserve cookies, handle CSRF and redirects, verify access, and capture protected pages reliably.

By the ScreenshotNeo team1 October 202610 min read

Direct answer: PHP cURL can access authenticated pages in two different ways. For HTTP authentication, send credentials with CURLOPT_USERPWD and choose an allowed scheme with CURLOPT_HTTPAUTH. For most website login forms, make a GET request to the login page, preserve its cookies, extract hidden fields such as CSRF tokens, POST the credentials, follow the expected redirect, and request the protected URL with the same cookie engine.

Do not confuse an HTTP 401 challenge with an HTML login form. They use different protocols and require different cURL options. PHP exposes libcurl’s cookie and authentication support through its cURL extension (PHP cURL documentation).

1. Choose the authentication flow

Flow What the server does PHP cURL approach
HTTP authentication Returns 401 Unauthorized with a WWW-Authenticate challenge Set CURLOPT_USERPWD and CURLOPT_HTTPAUTH
Form login Returns an HTML page containing a form, then creates a cookie or token session GET login page, keep cookies, parse hidden fields, POST form, then GET the protected page
JavaScript, CAPTCHA, WebAuthn or interactive MFA Requires browser execution or an interactive challenge Use the site’s supported API or browser automation; cURL alone is not a universal solution

HTTP authentication is described separately from form-login sessions in curl’s authentication documentation. A login page may also set an initial session cookie before you submit credentials, so the first GET matters.

2. Prepare a safe PHP cURL client

Use a private temporary cookie file, return the response body, follow only normal redirects, and keep TLS certificate verification enabled. Treat the cookie file like a password: anyone who can read it may be able to reuse the authenticated session.

<?php
declare(strict_types=1);

function createCurl(string $cookieFile): CurlHandle
{
    $ch = curl_init();
    if ($ch === false) {
        throw new RuntimeException('Unable to initialize cURL');
    }

    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS      => 5,
        CURLOPT_COOKIEJAR      => $cookieFile,
        CURLOPT_COOKIEFILE     => $cookieFile,
        CURLOPT_USERAGENT      => 'AuthenticatedPageFetcher/1.0',
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_TIMEOUT        => 60,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ]);

    return $ch;
}

function execute(CurlHandle $ch): string
{
    $body = curl_exec($ch);
    if ($body === false) {
        throw new RuntimeException(curl_error($ch));
    }
    return $body;
}

3. HTTP Basic, Digest, NTLM or Negotiate authentication

Use this flow only when the server challenges with HTTP authentication. CURLOPT_USERPWD supplies username:password; CURLOPT_HTTPAUTH controls which scheme libcurl may use. Basic authentication sends a base64-encoded value and must be used over HTTPS. libcurl also supports Digest, NTLM and Negotiate/SPNEGO where the server and build support them.

<?php
declare(strict_types=1);

$url = 'https://example.com/private/report';
$username = getenv('REPORT_USERNAME');
$password = getenv('REPORT_PASSWORD');

if ($username === false || $password === false) {
    throw new RuntimeException('Set REPORT_USERNAME and REPORT_PASSWORD');
}

$ch = curl_init($url);
if ($ch === false) {
    throw new RuntimeException('Unable to initialize cURL');
}

curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_MAXREDIRS      => 5,
    CURLOPT_USERPWD        => $username . ':' . $password,
    CURLOPT_HTTPAUTH       => CURLAUTH_BASIC,
    CURLOPT_USERAGENT      => 'AuthenticatedPageFetcher/1.0',
    CURLOPT_CONNECTTIMEOUT => 10,
    CURLOPT_TIMEOUT        => 60,
    CURLOPT_SSL_VERIFYPEER => true,
    CURLOPT_SSL_VERIFYHOST => 2,
]);

$body = curl_exec($ch);
if ($body === false) {
    throw new RuntimeException(curl_error($ch));
}

$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$finalUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
curl_close($ch);

if ($status < 200 || $status >= 300) {
    throw new RuntimeException("Unexpected HTTP status {$status} at {$finalUrl}");
}

echo $body;

If the server supports more than one scheme, let libcurl negotiate from the schemes advertised by the challenge, or select the strongest scheme your environment supports. Never send these credentials over plain HTTP.

4. Log in to a normal HTML form and keep the session

This is the common website-login pattern:

  1. Create one cURL handle and one cookie jar.
  2. GET the login page so the server can set its initial cookie.
  3. Parse the form action, hidden inputs and CSRF token.
  4. POST the actual field names plus the username, password and hidden values.
  5. Follow the expected redirect and verify that the result is authenticated content.
  6. Request the protected page with the same handle and cookie jar.

CURLOPT_COOKIEFILE enables cookie reading and CURLOPT_COOKIEJAR writes cookies back to disk. Setting a literal Cookie: header alone does not enable libcurl’s cookie engine.

<?php
declare(strict_types=1);

function absoluteUrl(string $base, string $action): string
{
    if ($action === '') {
        return $base;
    }
    if (preg_match('~^https?://~i', $action)) {
        return $action;
    }
    $parts = parse_url($base);
    if ($parts === false || !isset($parts['scheme'], $parts['host'])) {
        throw new RuntimeException('Invalid login URL');
    }
    $origin = $parts['scheme'] . '://' . $parts['host'];
    if (isset($parts['port'])) {
        $origin .= ':' . $parts['port'];
    }
    if (str_starts_with($action, '/')) {
        return $origin . $action;
    }
    $path = $parts['path'] ?? '/';
    $directory = rtrim(str_replace('\\', '/', dirname($path)), '/');
    return $origin . ($directory === '' ? '/' : $directory . '/') . $action;
}

function parseLoginForm(string $html, string $loginUrl): array
{
    libxml_use_internal_errors(true);
    $dom = new DOMDocument();
    if (!$dom->loadHTML($html)) {
        throw new RuntimeException('Login response was not valid HTML');
    }
    $xpath = new DOMXPath($dom);
    $form = $xpath->query('//form')->item(0);
    if (!$form instanceof DOMElement) {
        throw new RuntimeException('Login form not found');
    }

    $action = $form->getAttribute('action');
    $fields = [];
    foreach ($xpath->query('.//input', $form) as $input) {
        if (!$input instanceof DOMElement) {
            continue;
        }
        $name = $input->getAttribute('name');
        if ($name === '') {
            continue;
        }
        $type = strtolower($input->getAttribute('type'));
        if (in_array($type, ['submit', 'button', 'file'], true)) {
            continue;
        }
        $fields[$name] = $input->getAttribute('value');
    }

    return [absoluteUrl($loginUrl, $action), $fields];
}

$loginUrl = 'https://example.com/login';
$protectedUrl = 'https://example.com/account/private-report';
$username = getenv('SITE_USERNAME');
$password = getenv('SITE_PASSWORD');
if ($username === false || $password === false) {
    throw new RuntimeException('Set SITE_USERNAME and SITE_PASSWORD');
}

$cookieFile = tempnam(sys_get_temp_dir(), 'php-curl-cookie-');
if ($cookieFile === false) {
    throw new RuntimeException('Could not create a cookie file');
}
chmod($cookieFile, 0600);

$ch = null;
try {
    $ch = createCurl($cookieFile);

    curl_setopt_array($ch, [
        CURLOPT_URL => $loginUrl,
        CURLOPT_HTTPGET => true,
    ]);
    $loginHtml = execute($ch);
    [$postUrl, $fields] = parseLoginForm($loginHtml, $loginUrl);

    // Replace these names with the names used by the real form.
    $fields['email'] = $username;
    $fields['password'] = $password;

    curl_setopt_array($ch, [
        CURLOPT_URL => $postUrl,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query($fields, '', '&'),
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/x-www-form-urlencoded',
            'Referer: ' . $loginUrl,
        ],
    ]);
    $loginResult = execute($ch);
    $loginStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $afterLoginUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);

    // Adapt this marker to text that appears only for signed-in users.
    if ($loginStatus < 200 || $loginStatus >= 400 || str_contains($afterLoginUrl, '/login')) {
        throw new RuntimeException('Login did not reach the expected authenticated page');
    }

    curl_setopt_array($ch, [
        CURLOPT_URL => $protectedUrl,
        CURLOPT_HTTPGET => true,
        CURLOPT_POST => false,
        CURLOPT_POSTFIELDS => null,
        CURLOPT_HTTPHEADER => ['Accept: text/html,application/xhtml+xml'],
    ]);
    $protectedHtml = execute($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $finalUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);

    if ($status !== 200 || str_contains($finalUrl, '/login')) {
        throw new RuntimeException('Protected page was not returned; the session may have expired');
    }

    echo $protectedHtml;
} finally {
    if ($ch instanceof CurlHandle) {
        curl_close($ch);
    }
    if (is_file($cookieFile)) {
        unlink($cookieFile);
    }
}

The example intentionally leaves the field names and authenticated marker site-specific. Inspect the actual HTML and replace email, password, the form action and the success check. Some forms include multiple hidden inputs, a submit button value, a return URL or a CSRF token with a different name.

5. Verify that authentication really succeeded

A successful cURL call and a 200 status do not prove that you are logged in. Many sites redirect an unauthenticated request back to the login page and return that page with status 200.

  • Check CURLINFO_HTTP_CODE.
  • Check CURLINFO_EFFECTIVE_URL for an unexpected login or consent route.
  • Look for an authenticated-only marker such as an account heading or sign-out link.
  • Detect a login form in the protected response.
  • Inspect redirects during diagnosis, but never log passwords, authorization headers or cookie contents.

6. Cookies, redirects and session lifetime

Use the same handle or the same cookie jar for the login GET, credential POST and protected GET. A new handle without the jar starts a new session. Keep the jar in a directory with restrictive permissions and delete it after the job unless you intentionally need a reusable session.

Redirects

Set a small CURLOPT_MAXREDIRS value and inspect the final URL. A redirect from the POST to a login page usually means invalid credentials, a missing CSRF value, a rejected cookie, an incorrect host or an account policy check.

Cross-domain login

Single sign-on may move between several hostnames. Cookies are scoped by domain and path, so preserve the complete redirect flow and confirm that the final protected host receives the expected session cookie. Do not manually broaden cookie domains.

Expired sessions

If a later request returns a login page, start a fresh login flow. Do not assume a cookie jar remains valid indefinitely.

7. When PHP cURL is not enough

The generic recipe cannot complete JavaScript-generated tokens, CAPTCHA, WebAuthn or interactive MFA. If the site provides an official API, use it. Otherwise use an approved browser-automation flow that can execute the required JavaScript and handle the interactive step. Respect the site’s authorization, terms, rate limits, robots policy and account protections.

8. Troubleshooting common failures

Symptom Likely cause Fix
401 response HTTP authentication is required or the credentials are wrong Inspect WWW-Authenticate; use CURLOPT_USERPWD and an allowed CURLOPT_HTTPAUTH scheme
Always redirected to login Cookies were not persisted, the wrong host was used, or the login failed Set both cookie options, reuse the handle, inspect the final URL and verify an authenticated marker
CSRF or invalid form error Hidden fields or the token were omitted, or the token was stale GET the form immediately before POST, preserve every hidden input, and use the real field names
200 response containing the login form The session is unauthenticated despite the HTTP status Detect the login form and check for a signed-in-only marker
Cookie file is empty The directory is not writable, the response set no cookie, or the request failed before headers arrived Check permissions and cURL errors; confirm the response has Set-Cookie
SSL certificate error The certificate chain or hostname cannot be verified Fix the CA trust configuration; do not disable certificate verification
Works in a browser but not cURL JavaScript, CAPTCHA, MFA, browser fingerprinting or a required header is involved Use the supported API or browser automation and document the target-specific requirement
Timeouts or partial pages Slow server, long redirect chain or large response Set connect and total timeouts deliberately, retry only safe requests, and record status and timing data

9. Security checklist

  • Load credentials from a secret manager or environment variables.
  • Never put passwords in URLs, source control, logs or exception messages.
  • Restrict cookie-jar permissions to the worker account.
  • Delete temporary cookie files when the job ends.
  • Keep TLS verification enabled.
  • Limit redirect count and avoid following redirects to unexpected domains.
  • Use a dedicated account with the minimum access required.
  • Redact cookies and authorization headers from debug output.

10. Performance, reliability and cost

One login session can be reused for several protected requests until it expires, avoiding repeated authentication. Keep the login GET and POST close together, use connection reuse with one handle, and set explicit connect and total timeouts. Retry only idempotent GET requests, and use backoff for transient network failures. Do not blindly retry credential POSTs because the server may have accepted the login before the client lost the response.

Measure each stage separately: DNS and connection time, login response, redirect count, protected-page response and response size. Cache a valid session only when the site’s session policy allows it, and invalidate it after an authentication failure.

PHP cURL itself has no per-request service charge. Your operational costs come from the worker, network, storage and any third-party API or browser infrastructure. Respect the target site’s limits when choosing concurrency.

11. Or skip the browser setup

ScreenshotNeo provides a website screenshot API and MCP server. It can send custom headers, cookies, user agents and Authorization values when the target supports that access pattern, then capture the authenticated page as an image or PDF.

One request returns a screenshot. See the ScreenshotNeo API documentation for the available options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/account -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com/account"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/account' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Cookie banners, newsletter popups and chat widgets are removed before the shot. Bot checks, blank pages and failed loads are never billed, and the response identifies the page verdict and billing status in headers. An MCP server lets AI agents take screenshots through tools such as take_screenshot, get_page_info and capture_pdf. The Free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000.

Create a free ScreenshotNeo account and start with 1,000 screenshots each month at no charge.

12. FAQ

Use a cookie jar for a normal login flow. CURLOPT_COOKIE is appropriate only when you intentionally already have a cookie string; it does not make libcurl parse and persist cookies.

Why does the login page need a GET first?

The GET can set the initial session cookie and provides hidden inputs or CSRF values required by the credential POST.

Only if the target permits it and you protect the file. Concurrent writes can corrupt state, and sharing a live session increases the impact of a leak.

Does a 302 mean login succeeded?

No. Follow the redirect and verify the final URL and authenticated-only content.

Can cURL bypass CAPTCHA or MFA?

Not generically. Use the site’s supported integration or an approved interactive browser flow.