Secure file uploads with cURL and client certificates
To upload a file to an HTTPS endpoint that requires a client certificate, combine --upload-file,
--cert, and --key in the same cURL command. This walkthrough gives you a local mutual TLS (mTLS)
server, temporary certificates, and an upload you can verify byte for byte.
Separate server trust from client authentication
HTTPS encrypts the connection and lets cURL verify the server’s identity. With mTLS, the server also requires proof that the client holds a trusted certificate’s private key. These checks face opposite ways:
| cURL option | Purpose in this example |
|---|---|
--cacert ca.crt | Trust the CA that issued the server certificate; still check the URL’s hostname. |
--cert client.crt | Present the client’s public certificate to the server. |
--key client.key | Prove possession of the corresponding private key. |
A client certificate does not make an untrusted server trustworthy. Keep
cURL’s server verification enabled; --insecure would bypass it.
The temporary CA below is trusted only by these commands and this receiver, without changing your
system trust store.
Prepare the local tools
Prerequisites
Use a Linux shell with Bash, cURL built with OpenSSL, OpenSSL 3, Python 3, and cmp. No Python
packages are needed. Check the installed tools:
curl --version
openssl version
python3 --version
The cURL command uses --fail-with-body, available since cURL 7.76.0. See the
tested combinations below; this walkthrough does not establish behavior
for Windows certificate stores or other TLS backends.
Create temporary certificates
Run this from a directory where you can create mtls-demo. The subshell stops on errors and refuses
to reuse an existing directory. It leaves your terminal in the parent directory. All keys and
certificates stay inside mtls-demo, and the certificates expire after two days.
(
set -eu
umask 077
mkdir mtls-demo
cd mtls-demo
openssl req -x509 -newkey rsa:2048 -noenc -sha256 -days 2 \
-keyout ca.key -out ca.crt -subj '/CN=Local upload demo CA' \
-addext 'basicConstraints=critical,CA:TRUE' \
-addext 'keyUsage=critical,keyCertSign,cRLSign'
openssl req -new -newkey rsa:2048 -noenc \
-keyout server.key -out server.csr -subj '/CN=localhost'
printf '%s\n' 'basicConstraints=critical,CA:FALSE' \
'keyUsage=critical,digitalSignature,keyEncipherment' \
'extendedKeyUsage=serverAuth' 'subjectAltName=DNS:localhost' > server.ext
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key \
-set_serial 1 -days 2 -sha256 -extfile server.ext -out server.crt
openssl req -new -newkey rsa:2048 -noenc \
-keyout client.key -out client.csr -subj '/CN=Local upload client'
printf '%s\n' 'basicConstraints=critical,CA:FALSE' \
'keyUsage=critical,digitalSignature' 'extendedKeyUsage=clientAuth' > client.ext
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key \
-set_serial 2 -days 2 -sha256 -extfile client.ext -out client.crt
)
The certificate extensions give the server and client distinct roles. The server’s subject alternative
name is localhost, which must match the hostname in the upload URL. OpenSSL documents these
certificate extensions and the
-noenc option, which leaves these disposable keys
unencrypted. umask 077 restricts the new directory and files to your account.
Start a receiver that requires a client certificate
Save the following as mtls-demo/receiver.py. It accepts a raw PUT /upload with a known
Content-Length, including an empty body, up to 1 MiB. Each completed upload replaces
received.bin; rejected requests leave the previous file in place.
import argparse
import ssl
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
class UploadHandler(BaseHTTPRequestHandler):
def do_PUT(self):
if self.path != "/upload":
self.send_error(404, "Use /upload")
return
length = self.headers.get("Content-Length", "")
if self.headers.get("Transfer-Encoding") or not length.isascii() or not length.isdecimal():
self.send_error(411, "A Content-Length is required")
return
size = int(length)
if size > 1024 * 1024:
self.send_error(413, "Limit is 1 MiB")
return
self.connection.settimeout(10)
data = self.rfile.read(size)
if len(data) != size:
self.send_error(400, "Incomplete upload")
return
Path("received.bin").write_bytes(data)
reply = f"Stored {len(data)} bytes\n".encode()
self.send_response(201)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(reply)))
self.end_headers()
self.wfile.write(reply)
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=0)
args = parser.parse_args()
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.load_cert_chain("server.crt", "server.key")
context.load_verify_locations("ca.crt")
context.verify_mode = ssl.CERT_REQUIRED
with HTTPServer(("127.0.0.1", args.port), UploadHandler) as server:
server.socket = context.wrap_socket(server.socket, server_side=True)
print(f"https://localhost:{server.server_port}/upload", flush=True)
server.serve_forever()
Python’s CERT_REQUIRED rejects
clients without a valid certificate issued by a trusted CA. For this demo, every valid client
certificate from our CA can upload. A real service needs its own authorization policy too.
Start the receiver from the parent directory and leave it running:
(cd mtls-demo && python3 receiver.py)
It binds only to IPv4 loopback and prints an upload URL with an available port. Copy that URL for
the next step. A missing certificate or a bind error stops startup before any URL is printed.
This is a local learning server; Python’s
http.server is not intended for production.
Upload and compare the file
In a second terminal, open the same parent directory and create a small file. This command replaces
any existing payload.txt in the demo directory:
(cd mtls-demo && printf 'mTLS upload\n' > payload.txt)
In the block below, replace PORT with the receiver’s printed port. Run it from the parent directory:
(
set -eu
cd mtls-demo
upload_url='https://localhost:PORT/upload'
status=$(curl --disable --silent --show-error --fail-with-body \
--noproxy '*' --connect-timeout 5 --max-time 20 \
--cacert ca.crt --cert client.crt --key client.key \
--upload-file payload.txt --output response.txt --write-out '%{http_code}' \
"$upload_url")
cat response.txt
printf 'HTTP %s\n' "$status"
test "$status" = 201
cmp payload.txt received.bin
)
Expected output:
Stored 12 bytes
HTTP 201
cmp is silent when the source and received bytes match. For this endpoint, HTTP 201 means the
receiver wrote the file; it does not promise scanning, backups, or durable storage. The file remains
on disk when you stop the receiver.
--upload-file selects HTTP PUT with the file as the request
body. An API expecting a multipart POST needs a different request format; confirm the endpoint’s
method and body contract before adapting this command.
--fail-with-body makes HTTP errors return
cURL exit code 22, with the response body saved to response.txt. The subshell stops on that
failure. The explicit 201 check also rejects unexpected successful or redirect statuses. A received
response overwrites response.txt; an early TLS failure may leave an older response file, so do not
use that file alone as proof of success. --disable skips your default cURL configuration, and
--noproxy '*' keeps this loopback request out of environment-configured proxies.
Troubleshooting common issues
Read the cURL error before inspecting any saved response. To see the subshell’s exit status, run
echo "$?" immediately after the upload block. Use these checks one at a time, restoring the
successful command between attempts:
Certificate verification failed
Changing the URL’s hostname from localhost to 127.0.0.1 should produce cURL error 60: the
certificate names localhost, not the IP address. An unrelated CA supplied with --cacert also
fails verification. Use the server operator’s trusted CA and the hostname covered by the certificate;
do not solve either failure by disabling verification.
Client certificate errors
Remove --cert client.crt --key client.key and the TLS handshake should fail before the upload
handler runs. An untrusted client certificate also fails. The exact cURL code for a rejected
handshake can vary by TLS version and backend; it is different from an HTTP rejection.
Some builds report a send or receive error rather than a certificate-specific message.
Error 58 instead indicates trouble loading or using the local client credentials. Check that the
certificate is PEM, the private key matches it, and your account can read both files. Use the
certificate options appropriate to your TLS backend. In
cURL 8.22.0, a missing key file is rejected earlier with code 43 and a file-loading diagnostic.
HTTP rejection
Change /upload to /missing while keeping the valid certificates. TLS succeeds, but the server
returns HTTP 404 and cURL exits with 22. A file over 1 MiB is rejected with HTTP 413. Neither
case replaces received.bin. Check the current response body in mtls-demo/response.txt for these
HTTP failures; changing certificates will not fix a wrong path or an oversized file.
Security best practices
Keep ca.key, server.key, and client.key private, including any combined PEM file containing a
key. Do not commit them or upload the demo directory. Stop the receiver with Ctrl+C when finished,
then remove the disposable directory after checking that it contains nothing you want to keep.
Handling certificate passphrases
The demo’s keys are unencrypted so the local exercise runs without prompts. With an encrypted PEM
key and the OpenSSL backend, cURL can prompt for its passphrase. Never append the passphrase to
--cert or pass it as a command-line argument: that can expose it in shell history or process
arguments. Unattended jobs need a separate secret-delivery arrangement, such as a secrets manager
and a restricted credential file, with access limited to the service account.
Version compatibility
The complete local walkthrough was exercised on Linux with these combinations:
| Environment | cURL and its TLS backend | OpenSSL CLI | Python |
|---|---|---|---|
| Ubuntu 24.04 container | cURL 8.5.0, OpenSSL 3.0.13 | 3.0.13 | 3.12.3 |
| Arch-based host | cURL 8.22.0, OpenSSL 3.6.4 | 3.6.4 | 3.14.7 |
These checks cover file bytes and failure behavior with the local receiver. Windows, macOS, other TLS backends, and production upload services require their own verification.
