PHP SFTP: phpseclib, the ssh2 Extension, and Production-Safe Transfers
Most PHP SFTP examples stop after connecting and calling put(). They skip host-key verification, encrypted keys, partial uploads, retries, and what happens when a partner rotates credentials. This one does not.
Sending a file over SFTP from PHP looks easy, right up until the first production failure.
Most examples stop after connecting and calling put(). They rarely cover host-key verification, encrypted private keys, partial uploads, retries, overlapping cron runs, or what happens when a partner rotates credentials. PHP has two real SFTP options: phpseclib, a pure-PHP package installed with Composer, and the ssh2 PECL extension, a wrapper around libssh2. This guide covers both, with the details that keep a working example from becoming a fragile production job, and then an alternative: letting Files.com manage the partner connection so the PHP application never speaks SFTP.
Start with phpseclib
For most applications, phpseclib is the sensible default. It runs entirely in PHP, installs through Composer, and needs no server extension, which suits containers, managed platforms, and shared hosting where compiling PECL extensions is not an option.
composer require phpseclib/phpseclib:^3.0
A password-authenticated upload:
<?php
use phpseclib3\Net\SFTP;
$sftp = new SFTP('sftp.harborline.example', 22);
if (!$sftp->login('dana', getenv('SFTP_PASSWORD'))) {
throw new RuntimeException('SFTP login failed');
}
$uploaded = $sftp->put(
'/inbound/orders.csv',
'/var/app/exports/orders.csv',
SFTP::SOURCE_LOCAL_FILE
);
if (!$uploaded) {
throw new RuntimeException('SFTP upload failed');
}
The third argument to put() matters. Without SFTP::SOURCE_LOCAL_FILE, phpseclib treats the second argument as the contents to upload rather than a local filename, so $sftp->put('/inbound/orders.csv', '/var/app/exports/orders.csv') creates a remote file containing the literal text /var/app/exports/orders.csv. It is the easiest phpseclib mistake to miss.
The other methods you will use are get() to download, nlist() for a simple listing and rawlist() for detailed metadata, mkdir(), rename(), delete(), and chdir(). Absolute remote paths are the safer choice in scheduled jobs because they do not depend on the current directory.
Authenticate with an SSH key
Key-based authentication fits automated transfers better than a reusable password. phpseclib loads the common private-key formats through PublicKeyLoader:
<?php
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Net\SFTP;
$sftp = new SFTP('sftp.harborline.example', 22);
$keyContents = file_get_contents('/etc/app/keys/id_ed25519');
if ($keyContents === false) {
throw new RuntimeException('Could not read the SFTP private key');
}
$key = PublicKeyLoader::load($keyContents, getenv('SFTP_KEY_PASSPHRASE') ?: false);
if (!$sftp->login('dana', $key)) {
throw new RuntimeException('SFTP key authentication failed');
}
PublicKeyLoader reads OpenSSH, PuTTY, and PKCS formats, so the key a partner's administrator hands you loads without conversion. Treat the private key like any production secret: outside the web root, never in the repository, readable only by the user running PHP, with its passphrase in a secrets manager or a protected environment variable, one key per integration where possible, and a documented rotation process. The SSH keys post covers generating and installing them.
Verify the server's host key
Authentication answers one question: are you allowed to connect? Host-key verification answers another: did you connect to the right server?
phpseclib does not maintain an OpenSSH-style known_hosts file for your application. If you do not verify the host key yourself, your code authenticates to whatever answers at that hostname. Ask the server's owner for the expected host key or its SHA-256 fingerprint through a trusted channel, and verify it before sending credentials or files:
<?php
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Net\SFTP;
$sftp = new SFTP('sftp.harborline.example', 22);
$serverHostKey = $sftp->getServerPublicHostKey();
if ($serverHostKey === false) {
throw new RuntimeException('Could not retrieve the SFTP server host key');
}
$actualFingerprint = PublicKeyLoader::load($serverHostKey)->getFingerprint('sha256');
$expectedFingerprint = getenv('SFTP_HOST_KEY_SHA256');
if (!$expectedFingerprint || !hash_equals($expectedFingerprint, $actualFingerprint)) {
throw new RuntimeException(sprintf('SFTP host key mismatch: received %s', $actualFingerprint));
}
if (!$sftp->login('dana', getenv('SFTP_PASSWORD'))) {
throw new RuntimeException('SFTP login failed');
}
You can pin the complete public host key instead of its fingerprint. Either way, keep the expected value in protected configuration rather than in code, and treat a mismatch as a stop, not a warning. It may mean a man-in-the-middle attack, a DNS or routing problem, the wrong endpoint, or a legitimate server rebuild, and even the legitimate case gets verified with the server's owner before the pinned value changes.
When to use the ssh2 PECL extension
PHP's other option is the ssh2 PECL extension, which wraps libssh2. Most of the SSH work happens in native code, so it performs well for large or frequent transfers. The tradeoff is operational: the extension and a compatible libssh2 have to be installed everywhere the application runs.
<?php
$conn = ssh2_connect('sftp.harborline.example', 22);
if ($conn === false) {
throw new RuntimeException('Could not connect to SFTP server');
}
$actualFingerprint = ssh2_fingerprint($conn, SSH2_FINGERPRINT_SHA256 | SSH2_FINGERPRINT_HEX);
$expectedFingerprint = getenv('SFTP_HOST_KEY_SHA256_HEX');
if ($actualFingerprint === false || !$expectedFingerprint
|| !hash_equals(strtolower($expectedFingerprint), strtolower($actualFingerprint))) {
throw new RuntimeException('SFTP host key mismatch');
}
if (!ssh2_auth_pubkey_file($conn, 'dana', '/etc/app/keys/id_ed25519.pub',
'/etc/app/keys/id_ed25519', getenv('SFTP_KEY_PASSPHRASE') ?: null)) {
throw new RuntimeException('SFTP authentication failed');
}
$sftp = ssh2_sftp($conn);
if ($sftp === false) {
throw new RuntimeException('Could not initialize SFTP');
}
if (!copy('/var/app/exports/orders.csv', "ssh2.sftp://{$sftp}/inbound/orders.csv")) {
throw new RuntimeException('SFTP upload failed');
}
The extension exposes SFTP files through the ssh2.sftp:// stream wrapper, so ordinary PHP functions such as fopen(), file_get_contents(), and copy() work with remote paths. Exact support for key formats, algorithms, and fingerprint flags depends on the installed versions of the extension and libssh2, so test against the packages production runs. The rule of thumb: phpseclib for portability and simple deployment, ssh2 when it is already part of your server image and native performance matters.
Build a transfer job, not just an upload call
A scheduled SFTP integration needs more than a successful put().
Publish files atomically. Do not upload directly to the filename the partner watches, because their system may read it before the transfer finishes. Upload to a temporary name and rename on success:
$tempPath = '/inbound/.orders.csv.' . bin2hex(random_bytes(6)) . '.part';
$finalPath = '/inbound/orders.csv';
if (!$sftp->put($tempPath, '/var/app/exports/orders.csv', SFTP::SOURCE_LOCAL_FILE)) {
throw new RuntimeException('Temporary upload failed');
}
if (!$sftp->rename($tempPath, $finalPath)) {
$sftp->delete($tempPath);
throw new RuntimeException('Could not publish uploaded file');
}
A rename on the same remote filesystem is typically atomic. Confirm the partner's overwrite rules before using a fixed final filename; some servers will not rename over an existing file.
Prevent overlapping runs with a lock such as flock() on a lock file, or your framework's scheduler lock, so cron cannot start a second transfer while the first is still running. Retry only transient failures, a network timeout rather than a rejected key or a host-key mismatch, with a few attempts at increasing delays and a fresh connection each time, and give up loudly. Set a timeout with $sftp->setTimeout(30) so a hung transfer does not occupy a worker forever. Log the timestamp, the partner, the filenames, the size, the attempt, the duration, the result, and a safe error message, and never a password, key, passphrase, or connection object. Exit nonzero on failure so cron, a container job, or a monitor sees it. And decide what happens when the same business file is uploaded twice: unique filenames with a batch ID, a local record of completed transfers, checksums, or an archive step for successful local files. Transport retries and business-level duplicate handling are separate concerns, and a reliable job has both.
When SFTP no longer belongs in application code
For one partner and one daily file, an in-application SFTP job is reasonable. As the integrations multiply, the PHP code owns more infrastructure: partner credentials and key rotation, host-key pinning, outbound firewall access on port 22, retry and backoff, transfer logs and alerts, scheduling and concurrency, per-partner directory and filename rules, and library and cryptographic updates. At that point, moving partner connectivity out of the application simplifies operations.
Let Files.com manage the partner connection
A Files.com Remote Server Mount exposes a partner's SFTP server as a folder on your Files.com site. Files.com holds the credentials, can generate the SSH key pair itself so the private key never touches the PHP server, pins the partner's host key and stops using the connection if that key changes, and passes every read and write through to the partner's server in real time. The application boundary changes from "PHP to SFTP to partner" to "PHP to HTTPS to Files.com to SFTP to partner."
The PHP then uses the Files.com PHP SDK, one of seven official SDKs generated from the same API definition, with an API key and typed errors instead of SFTP connection management. For a script that wants no library at all, the Files.com CLI uploads the file:
files-cli upload /var/app/exports/orders.csv /partners/harborline/inbound/
When no application code is needed, a scheduled Sync moves the files and an Automation routes what arrives. And Files.com accepts inbound SFTP, so a partner's own phpseclib client keeps working against your Files.com hostname while Files.com handles the mounted destination behind it. The approach pays off when partner transfers have become shared infrastructure rather than a one-off feature. The Python, Java, Node.js, and C# posts show the same pattern in their languages.
Which approach to choose
For a new PHP integration, start with phpseclib unless you have a specific reason not to. Verify the host key, upload under a temporary name, rename only after success, and make failures visible. Reach for the ssh2 extension when it is already installed and native performance matters. When every new partner requires another set of credentials, firewall rules, retry logic, and monitoring, the problem is no longer "how to upload a file." It has become integration infrastructure, and it is time to manage it outside the PHP application.
Frequently asked questions
How do I upload a file over SFTP in PHP?
With phpseclib 3, create a phpseclib3\Net\SFTP, verify the server's host key, call login() with a password or a loaded private key, then call $sftp->put('/remote/path/file.csv', '/local/path/file.csv', SFTP::SOURCE_LOCAL_FILE). The third argument marks the second as a local filename rather than the contents to upload.
Does PHP have built-in SFTP support?
No. SFTP is not in PHP core. The common options are phpseclib, installed through Composer, and the ssh2 extension, installed through PECL or an operating-system package.
How do I use an SSH key with phpseclib?
Load the private key with phpseclib3\Crypt\PublicKeyLoader::load(), supplying its passphrase if it has one, and pass the resulting key object to login().
Does phpseclib verify the server's host key automatically?
Not against a key your application trusts. Retrieve the server key with getServerPublicHostKey(), compare it or its fingerprint to the expected value, and stop the connection on a mismatch. Never treat a mismatch as a warning.
How do I stop a partner from reading a partial upload?
Upload the file under a temporary remote name such as .orders.csv.part, and rename it to the final name only after the upload completes. Confirm the temporary and final paths are on the same remote filesystem.
Can Files.com connect to a partner's SFTP server for me?
Yes. A Remote Server Mount makes the partner's SFTP server a folder on your Files.com site. Files.com manages the connection, the key, and the host key, and the PHP application uploads to that folder over HTTPS through the SDK, the CLI, or the API.
Keep reading
- Node.js SFTP: ssh2-sftp-client, the OpenSSH Client, and a Way Without EitherNode.js SFTP with working code: ssh2-sftp-client with a real host key check, the OpenSSH sftp binary driven from Node, and a third way where Files.com makes the SFTP connection so the process never speaks the protocol. Keys in a container and failing loudly on a schedule.
- Python SFTP: Paramiko, pysftp, and a Way That Needs NeitherPython SFTP with working code: uploading, downloading, and listing files with paramiko, why to skip pysftp, and a third way where Files.com makes the SFTP connection so the script never speaks the protocol. SSH keys, host verification, and running it from cron.
- curl FTP, FTPS, and SFTP: Upload, Download, and Script Transfers From the Command Linecurl speaks FTP, FTPS, and, depending on the build, SFTP. The commands for uploading and downloading over each, the flags that matter, why the macOS and Windows curls lack SFTP, how to keep credentials out of the command, and how to run any of it from a scheduled job.
- SFTP API: What People Mean by It, and the Three Options That ExistSFTP has no HTTP API. When someone searches for an SFTP API they mean a library that speaks SFTP, a way to automate transfers, or an HTTP API in front of an SFTP server. What each one is, what each one asks of you, and how to put an API in front of a partner's server you do not control.