PHP transport impersonation
A deployment configuration, not a library feature. PHP's stock
ext-curl presents an unmistakably non-browser TLS fingerprint, and
many targets refuse it before any captcha is ever shown. The SDK does not
implement impersonation — it tells you whether the impersonation you configured
actually took effect.
Why this exists
A refusal at the TLS handshake is invisible to a solver: nothing was challenged, so nothing can be solved. If you record that as a solving failure you blame the solver for a transport problem and corrupt the one number the product is built on. So it is separated: transport shape is a deployment concern, captcha handling is the SDK's.
Symptomatically this looks like every request reports
no_challenge but nothing works — blocks, 403s, or an
interstitial you never see a captcha inside.
How it is done
Preload curl-impersonate's shared library so ext-curl links
against it instead of the system libcurl. No application code changes.
LD_PRELOAD=/usr/local/lib/libcurl-impersonate-chrome.so \
CURL_IMPERSONATE=chrome116 \
php your-script.php
That is the whole mechanism. Every request the process makes through libcurl — including the SDK's own calls to the Legibl API — goes out with the chosen browser's handshake.
The constraint that decides your base image
A preloaded .so is dynamically linked against a C library. A
musl-linked build will not load into a glibc image, and the reverse is equally
true. The published curl-impersonate builds are musl-linked,
which makes Alpine PHP the straightforward host.
| Base image | libc | Published build works | What to do |
|---|---|---|---|
php:8.3-alpine, php:7.4-alpine |
musl | yes | Drop the .so in and set the two environment
variables. |
php:8.3-cli, php:8.3-fpm (Debian) |
glibc | no | Build curl-impersonate against glibc yourself, or move that workload to an Alpine image. |
| A distro PHP on Ubuntu/Debian/RHEL | glibc | no | As above. |
The failure is quiet in the worst way: the dynamic loader declines to preload
a library it cannot use, the process starts normally, and every request goes out
with the stock fingerprint while CURL_IMPERSONATE sits in the
environment suggesting otherwise. That case has its own status —
misconfigured — and you should alert on it.
A working Alpine image
FROM php:8.3-alpine
RUN apk add --no-cache curl ca-certificates
# Take the musl build for your architecture from the curl-impersonate
# project's releases and unpack the shared library into /usr/local/lib.
ARG CURL_IMPERSONATE_TARBALL=libcurl-impersonate-vX.Y.Z.x86_64-linux-musl.tar.gz
COPY ${CURL_IMPERSONATE_TARBALL} /tmp/
RUN tar -xzf /tmp/${CURL_IMPERSONATE_TARBALL} -C /usr/local/lib \
&& rm /tmp/${CURL_IMPERSONATE_TARBALL}
ENV LD_PRELOAD=/usr/local/lib/libcurl-impersonate-chrome.so \
CURL_IMPERSONATE=chrome116
COPY . /app
WORKDIR /app
CMD ["php", "worker.php"]
Pin the release and the architecture explicitly. An
x86_64-linux-musl library on an aarch64 host fails exactly the same
way a glibc one does on Alpine, and just as silently.
Verify it took effect
Setting the environment variable and believing you are protected is the likeliest failure here, so the SDK checks a property of the loaded library rather than of the environment: curl-impersonate builds against BoringSSL, while a stock PHP build uses OpenSSL, GnuTLS, NSS or similar. That cannot be faked by exporting a variable.
use Legibl\Impersonation;
Impersonation::isActive(); // bool — BoringSSL present in curl_version()
Impersonation::requestedProfile(); // 'chrome116', or null
Impersonation::status(); // 'active' | 'inactive' | 'misconfigured'
Impersonation::explain(); // human-readable guidance
Impersonation::describe(); // diagnostic array, safe to log
{
"status": "active",
"requested_profile": "chrome116",
"curl_version": "8.1.2",
"ssl_backend": "BoringSSL"
}
Config::describe() includes the status too, so if you already log
the resolved configuration at boot you get this for free.
| Status | Means | Action |
|---|---|---|
active |
BoringSSL is loaded; requests carry the browser handshake. | None. |
inactive |
No profile requested and no library loaded. Requests carry the stock PHP fingerprint. | Fine if intended. Some targets refuse this before presenting a captcha. |
misconfigured |
A profile was requested but the library is not loaded. You believe you are impersonating a browser and you are not. | Check the LD_PRELOAD path, the architecture, and the libc
match. This is the case to alert on. |
explain() for the misconfigured case says exactly this:
CURL_IMPERSONATE=chrome116 is set but curl-impersonate is not loaded — requests
are going out with the stock PHP fingerprint. Set LD_PRELOAD to the
curl-impersonate shared library built for this image's libc.
What it measurably changes
Verified against tls.peet.ws:
| Configuration | JA4 | Cipher suites | Extensions |
|---|---|---|---|
Stock ext-curl |
t13d3113h2_… |
31 | 13 |
| With the preload | t13d1516h2_… |
15 | 16 |
The preloaded configuration also presents a matching Chrome user agent.
Things to know before enabling it
- It is process-wide.
LD_PRELOADaffects every libcurl user in the process, not just the requests you meant. Scope it to the worker that needs it rather than exporting it into a whole host. - It is not a captcha feature. It changes what you get
challenged with, not how challenges get solved. It will not turn an
unhandledoutcome into asolvedone. - PHP only. There is no equivalent shipped for the Python SDK. httpx and requests do not go through libcurl, so this mechanism does not apply to them.
- Pin the profile.
chrome116is a specific build's fingerprint. Browser fingerprints age; treat the profile as something you review, not something you set once.