curl mTLS on Linux and Windows: PEM, P12 and Schannel
Call an mTLS API with curl on Linux or Windows. Choose PEM or P12 for your TLS backend, handle passwords, and switch generated client identities.
To call an mTLS API with curl, supply a client certificate and its private key while continuing to verify the server. With an OpenSSL build, that can be two PEM files. With a modern Windows Schannel build, use a P12/PFX bundle or select an identity from a Windows certificate store.
Windows does not universally require a permanent certificate-store import. Schannel curl gained direct P12/PFX client-file support in curl 7.70.0. This matters when tests generate many client certificates: each identity can remain in its own protected bundle instead of becoming another permanent store entry.
The commands below assume you already have an issued client certificate, its matching private key, and an API configured to accept that identity. Replace https://api.example.test/health with your API URL; it is a placeholder, not a test service. Filenames are synthetic. Windows examples are verified against documentation and source, but were not executed on Windows; the Linux checks are listed below.
Check the executable and TLS backend first
On Linux, or inside a WSL Linux shell:
command -v curl
curl --version
openssl version
In Windows PowerShell:
Get-Command curl -All
Get-Command curl.exe -All
curl.exe --version
Read the TLS library in the version output, such as OpenSSL or Schannel. The operating system or shell name alone does not establish which certificate formats your executable supports. Windows PowerShell 5.1 also aliases curl to Invoke-WebRequest; curl.exe selects an executable. PowerShell 7 has no default curl alias. Microsoft's curl guide explains this distinction.
| Actual TLS backend | Client identity workflow |
|---|---|
| OpenSSL | PEM certificate with a separate PEM private key; P12 is also supported |
| Schannel, curl 7.70.0 or later | P12/PFX file, subject to the build and Windows crypto support |
| Schannel with an installed identity | Explicit certificate-store selector; the identity must have an accessible private key |
For Schannel, a separate --key client.key does not supply the missing key: Schannel ignores CURLOPT_SSLKEY. Choose a bundle containing the key or a store identity that already has it.
Separate client identity from server trust
mTLS authenticates both sides, with different inputs:
| Option or file | Purpose |
|---|---|
--cert client-a.pem or --cert client-a.p12 | Select the client's identity |
--key client-a.key | Supply the matching private key on backends such as OpenSSL |
--cacert server-ca.pem | Supply CA certificates for verifying the server |
A server CA file cannot replace your client private key. The CA that issued the client certificate may also differ from the CA that issued the server certificate. These are separate trust relationships. See curl's client certificate guide and server certificate verification guide.
Schannel normally uses Windows native CA trust. A private API may need a dedicated server-ca.pem; Schannel support for --cacert was added in curl 7.60.0 and requires Windows 7 or later. This transfer-specific option can avoid changing machine-wide root trust. See the upstream --cacert documentation.
Use PEM files with an OpenSSL build
In Bash, using a curl build whose version output confirms OpenSSL:
curl --cacert server-ca.pem --cert client-a.pem --key client-a.key https://api.example.test/health
If the server is already trusted through the build's normal CA configuration, the explicit --cacert file may be unnecessary. Keep server verification enabled either way. An encrypted private key can use OpenSSL's terminal password prompt during an interactive call; automation needs an appropriate protected secret mechanism.
You can use the same PEM workflow on Windows with a compatible curl build. Select its executable explicitly in PowerShell:
& 'C:\Tools\curl-openssl\bin\curl.exe' --cacert .\server-ca.pem --cert .\client-a.pem --key .\client-a.key https://api.example.test/health
That path is illustrative. Check the executable you actually install; installing openssl.exe alone does not change another curl binary's TLS backend, and a Windows curl download is not necessarily built with OpenSSL.
Use a P12/PFX file with Schannel
PKCS#12, commonly named .p12 or .pfx, bundles a client certificate and its private key. If your certificate generator produces separate PEM files, export a bundle with OpenSSL:
openssl pkcs12 -export -out client-a.p12 -in client-a.pem -inkey client-a.key
OpenSSL asks for an export password interactively. Retain that password through your secret-handling mechanism. If the API requires client intermediates, add -certfile client-intermediates.pem; those must be the client's chain, not an unrelated server CA. Renaming a PEM file to .pfx does not convert it. The OpenSSL PKCS#12 reference documents these export options.
Provision a file named client-secret.curl through a trusted editor or secret mechanism. Protect its directory and file permissions before placing the password in it. Restrict Windows ACLs to the executing identity; on Linux, use an owner-only directory and file permissions such as mode 600. Its contents are:
pass = "P12_PASSWORD_FROM_YOUR_SECRET_SOURCE"
Replace that placeholder with the bundle's actual password. This config file is plaintext: exclude it from Git and logs, and remove temporary copies after use. Curl config strings have escaping rules for quotes and backslashes, so automation must serialize arbitrary passwords correctly rather than concatenate them into this example. A tool able to securely supply stdin can instead use --config -; account for any other use of stdin. See curl's --config reference.
Then run in PowerShell:
curl.exe --cert-type P12 --cert .\client-a.p12 --config .\client-secret.curl --cacert .\server-ca.pem https://api.example.test/health
This keeps the password out of the process command line. Do not assume Schannel will prompt if you omit the password: prompting depends on the backend. The bundle itself also contains sensitive private-key material and needs protection.
Why do some guides still require an import? As checked on November 1, 2026, the curl CLI manpage's --cert section still said Schannel could not load PFX files. That conflicts with the 7.70.0 release notes, the current client-certificate API documentation, and the Schannel implementation. The implementation loads the P12 into an in-memory store with PKCS12_NO_PERSIST_KEY; it does not require a prior permanent Personal-store import. This still uses Windows cryptography and does not remove the original bundle from disk. Older versions, vendor builds and OS crypto compatibility can constrain the file path.
Optional: select an installed Windows identity
A store identity can be convenient when your organization already provisions certificates there. Client identities normally belong in the user's Personal (My) store with their private keys. Trusted Root serves a different purpose: establishing CA trust. Putting a client leaf certificate there does not supply a private key. Microsoft describes these certificate stores.
For an intentional current-user import, PowerShell provides a password prompt:
$pfxPassword = Read-Host 'PFX password' -AsSecureString
$imported = Import-PfxCertificate -FilePath .\client-a.p12 -CertStoreLocation Cert:\CurrentUser\My -Password $pfxPassword
$identity = $imported | Where-Object HasPrivateKey
$identity | Select-Object Subject, Thumbprint, HasPrivateKey
Inspect the result and select the intended leaf certificate; a PFX may contain multiple certificates. Copy its full thumbprint without display spaces into this Schannel selector:
curl.exe --cert 'CurrentUser\MY\FULL_HEX_THUMBPRINT' --cacert .\server-ca.pem https://api.example.test/health
The curl selector differs from PowerShell's Cert:\CurrentUser\My provider path. No separate --key is needed. Curl documents the selector syntax; Microsoft documents the import.
CurrentUser means the account running the process. A service or CI job may not see your interactive user's store or have permission to use its private key. Record test identities you import and remove only those you own when finished.
Switch many generated client certificates
For dynamically generated identities, keep one matching PEM pair or P12 bundle per identity, with a controlled lifetime and access permissions. This avoids recurring permanent imports and thumbprint bookkeeping. It is a workflow choice, not a performance claim.
Start with one fresh curl process for each identity. For example, repeat the OpenSSL call using the second pair:
curl --cacert server-ca.pem --cert client-b.pem --key client-b.key https://api.example.test/health
For Schannel, select client-b.p12 and the protected configuration containing its password. Repeating --cert in one command does not create an identity pool: the last value wins. Long-running clients also need deliberate connection and TLS session handling when identities change.
WSL is another way to run a Linux curl workflow on Windows. Run command -v curl and curl --version inside WSL, and use Linux paths such as /mnt/c/... when accessing Windows files. Running curl.exe from WSL still runs a Windows executable. Git Bash likewise does not establish the TLS backend. Microsoft documents WSL filesystem and executable interoperability.
When another tool uses curl
If the tool launches curl, inspect its configured executable path and run that exact binary's --version. Check the account, working directory and file permissions used by the tool.
If it embeds libcurl, changing PATH's curl executable generally cannot change its linked TLS backend. Use the application's diagnostics; developers can inspect curl_version_info(...)->ssl_version. An application might expose only store selection even though its library supports P12 files. Developers controlling the integration can also consider CURLOPT_SSLCERT_BLOB, added in 7.71.0, with P12 support on Schannel and PEM/P12 on OpenSSL. Third-party tools must expose the necessary options themselves.
Diagnose the layer that failed
| Symptom | Check next |
|---|---|
| Local certificate/key load error, often curl 58 | TLS backend, format, matching key, file access and P12 password |
| Server verification error, often curl 60 | Server CA chain and URL hostname/SAN; a client certificate does not fix server trust |
| TLS alert such as “certificate required” or “bad certificate” | Server client-auth policy, accepted client issuer, client chain, validity and client-auth EKU |
| HTTP 401 or 403 | API authorization; TLS can succeed while the API rejects the identity or missing application credentials |
A TLS failure may happen before the API receives any HTTP request. Exact error strings and exit codes vary by backend, TLS version and server behavior. -k disables server verification and does not supply a missing client identity. Investigate Schannel revocation/network errors separately instead of routinely disabling revocation checks.
For a P12 import or password error, inspect the bundle interactively:
openssl pkcs12 -info -noout -in client-a.p12
A reported wrong password can also reflect Windows format compatibility. Microsoft documents older Windows systems rejecting AES256-SHA256 PFX files with a misleading password error. Prefer a supported OS/build; do not make legacy encryption or unencrypted key extraction your default workaround.
Use synthetic identities for diagnostics, redact verbose/trace logs before sharing, and never upload private keys or P12 bundles to hosted request inspectors. For HTTP headers and shell quoting after the TLS connection works, see curl headers on Linux and Windows.
What was tested
The research checks used Linux x86_64, curl/libcurl 8.5.0, Ubuntu package 8.5.0-2ubuntu10.16, and OpenSSL 3.0.13. A temporary Python HTTPS server on loopback required client certificates and used a localhost SAN server certificate. A temporary CA issued separate client-a and client-b identities with appropriate client-auth usage.
| Local check | Observed result |
|---|---|
| Client A PEM/key and correct server CA | curl exit 0, HTTP 200 |
| Client B PEM/key and correct server CA | curl exit 0, HTTP 200 |
| Client A P12 with password from protected curl config and correct server CA | curl exit 0, HTTP 200 |
| No client identity, correct server CA | curl exit 55, HTTP 000 |
| Valid client identity, missing server CA | curl exit 60 |
The missing-identity exit code is an observation from that setup, not a universal expected code. These checks establish the Linux file-based TLS flow, not authorization in your API. Temporary files and the server were removed afterward; no production trust store was changed. Windows Schannel, PowerShell import, Windows alternative builds, WSL interoperability and embedded-libcurl examples were not runtime-tested.
Inspect your next webhook with WebhookKit.