FTP (File Transfer Protocol, RFC 959) separates control and data over two connections — a design that creates unique firewall and security challenges. While plain FTP should never be used over untrusted networks, its secure variants (FTPS and SFTP) remain widely deployed for automated file transfers in hosting, EDI, and enterprise batch processing. As of April 2026, Censys observes roughly 5.94 million internet-facing FTP hosts, the majority running on shared hosting platforms as default infrastructure.
This guide covers FTP’s active and passive modes with Mermaid diagrams, a complete protocol reference (commands, reply codes, representation types), Python client examples with FTPS over TLS and SFTP over SSH, vsftpd server configuration including virtual users and TLS 1.3, enterprise automation patterns, and a decision framework for choosing between FTP, FTPS, and SFTP.
How FTP Works
FTP uses a client-server architecture with two separate connections: a control connection (port 21) for commands and replies, and a data connection (dynamically assigned port) for file content.
sequenceDiagram
participant Client
participant Server
Note over Client,Server: Control Connection (port 21)
Client->>Server: USER anonymous
Server-->>Client: 331 Password required
Client->>Server: PASS [email protected]
Server-->>Client: 230 Login successful
Client->>Server: LIST
Server-->>Client: 150 Here comes the listing
Note over Client,Server: Data Connection (port varies by mode)
Server-->>Client: [file listing data]
Server-->>Client: 226 Transfer complete
Client->>Server: QUIT
Server-->>Client: 221 Goodbye
Active Mode
In active mode, the client opens a random port and asks the server to connect back to it:
sequenceDiagram
participant Client
participant Server
Note over Client: Opens random port, e.g. 50123
Client->>Server: PORT 192,168,1,5,195,203
Note over Client,Server: (195*256+203 = 50123)
Server->>Client: Connects to 192.168.1.5:50123
Server-->>Client: 200 PORT command successful
Client->>Server: RETR file.txt
Server-->>Client: [file data over data connection]
Active mode fails when the client is behind NAT because the server cannot reach the client’s random port. This is why active mode is rarely used on modern networks.
Passive Mode
In passive mode, the server opens a random port and the client connects to it, which works through NAT:
sequenceDiagram
participant Client
participant Server
Client->>Server: PASV
Server-->>Client: 227 Entering Passive Mode (220,10,10,10,128,32)
Note over Server: Opens port 128*256+32 = 32800
Client->>Server: Connects to 220.10.10.10:32800
Client->>Server: RETR file.txt
Server-->>Client: [file data over data connection]
Passive mode is the default for modern FTP clients because it works through NAT and firewalls. The server must have its passive port range configured and open in the firewall.
Extended Passive Mode (EPSV)
EPSV (RFC 2428) improves on PASV by eliminating the need to parse IP addresses in the server response. The server replies with only the port number, making it compatible with IPv6:
Client: EPSV
Server: 229 Entering Extended Passive Mode (|||32800|)
EPSV is preferred over PASV on modern networks, especially when IPv6 is in use.
FTP Protocol Reference
Core FTP Commands
| Command | Arguments | Description |
|---|---|---|
USER |
username | Authentication username |
PASS |
password | Authentication password |
ACCT |
account | Account information (rarely used) |
CWD |
pathname | Change working directory |
CDUP |
— | Change to parent directory |
PWD |
— | Print working directory |
LIST |
pathname | List files (data connection) |
NLST |
pathname | List file names only (data connection) |
RETR |
pathname | Retrieve (download) a file |
STOR |
pathname | Store (upload) a file |
APPE |
pathname | Append to an existing file |
DELE |
pathname | Delete a file |
RMD |
pathname | Remove a directory |
MKD |
pathname | Create a directory |
RNFR |
pathname | Rename from (source) |
RNTO |
pathname | Rename to (destination) |
ABOR |
— | Abort an active file transfer |
QUIT |
— | Disconnect |
SYST |
— | Return system type (e.g., UNIX) |
STAT |
pathname | Return server or file status |
HELP |
command | Return help information |
SIZE |
pathname | Return file size (RFC 3659) |
MDTM |
pathname | Return file modification time (RFC 3659) |
MLSD |
pathname | List files in machine-readable format (RFC 3659) |
MLST |
pathname | Single-file machine-readable listing (RFC 3659) |
FEAT |
— | List supported extensions (RFC 2389) |
OPTS |
command | Set options for a command (RFC 2389) |
REST |
offset | Restart transfer from byte offset (RFC 3659) |
AUTH |
mechanism | Start security negotiation (RFC 2228) |
PBSZ |
size | Protection buffer size (RFC 2228) |
PROT |
level | Data channel protection level (RFC 2228) |
EPRT |
address | Extended PORT for IPv6 (RFC 2428) |
EPSV |
— | Extended passive mode for IPv6 (RFC 2428) |
FTP Reply Codes
FTP replies use three-digit codes grouped by category:
| Code | Category | Examples |
|---|---|---|
| 1xx | Positive preliminary reply | 150 Opening data connection |
| 2xx | Positive completion reply | 200 Command OK, 226 Transfer complete, 230 Login successful |
| 3xx | Positive intermediate reply | 331 User name OK, need password |
| 4xx | Transient negative completion | 425 Can't open data connection, 450 File unavailable |
| 5xx | Permanent negative completion | 500 Syntax error, 530 Not logged in, 550 File not found |
Common codes in practice:
220 — Service ready for new user
221 — Service closing control connection
226 — Transfer complete (data connection closed)
227 — Entering Passive Mode (h1,h2,h3,h4,p1,p2)
229 — Entering Extended Passive Mode (|||port|)
230 — User logged in, proceed
331 — User name OK, need password
425 — Can't open data connection
450 — Requested file action not taken (file unavailable)
500 — Syntax error, command unrecognized
530 — Not logged in
550 — Requested action not taken (file not found)
Representation Types (TYPE Command)
The TYPE command controls how file data is interpreted during transfer:
| Type | Argument | Description | Use Case |
|---|---|---|---|
| ASCII | A |
Text with newline conversion | Text files, source code |
| Binary / Image | I |
Raw byte stream | Images, archives, binaries |
| EBCDIC | E |
IBM mainframe character set | Legacy mainframe systems |
| Local byte size | L n |
Variable byte size | Specialized hardware |
Modern FTP always uses Binary (Image) mode, set via TYPE I. ASCII mode can corrupt binary files by converting line endings. The ftplib Python library defaults to ASCII unless voidcmd('TYPE I') is called explicitly.
File Transfer Structures and Modes
Structures (STRU command):
| Structure | Description |
|---|---|
FILE (F) |
No internal structure — continuous stream of bytes (default) |
RECORD (R) |
File is a sequence of records (used with TEXTBLOCK mode) |
PAGE (P) |
File is a sequence of indexed pages |
Transfer Modes (MODE command):
| Mode | Description |
|---|---|
STREAM (S) |
Data sent as a continuous stream; EOF closes connection (default) |
BLOCK (B) |
Data sent in blocks with header bytes (DESCRIPTOR, COUNT) |
COMPRESSED (C) |
Data compressed using run-length encoding |
Stream mode is universal. Block and compressed modes are rarely used in practice.
Explicit vs Implicit FTPS
FTP over TLS comes in two flavors:
Explicit FTPS (FTPES) — the client connects to port 21 and sends AUTH TLS to upgrade the plaintext control channel to encrypted. Data channels are encrypted via PROT P. This is the standard approach defined in RFC 4217 and supported by all modern clients.
Client: AUTH TLS
Server: 234 AUTH TLS OK
[TLS handshake occurs]
Client: USER john
Server: 331 Password required
[All subsequent communication encrypted]
Implicit FTPS — the client connects directly to port 990 and a TLS handshake is required immediately. No plaintext communication ever occurs. This approach predates RFC 4217 and is less common today, but some legacy clients and trading partner systems still require it.
| Feature | Explicit (FTPES) | Implicit (FTPS) |
|---|---|---|
| Port | 21 (then upgrade) | 990 (immediate TLS) |
| Standard | RFC 4217 | Pre-standard |
| Firewall | Single port 21 | Port 990 (+ passive range) |
| Client support | Universal | Most clients, but configurable |
| Recommended | Yes | Legacy only |
vsftpd Server Configuration
Install and configure vsftpd (Very Secure FTP Daemon) on Ubuntu 24.04:
sudo apt update
sudo apt install vsftpd
# Backup default config
sudo cp /etc/vsftpd.conf /etc/vsftpd.conf.backup
Minimal Secure Configuration
# /etc/vsftpd.conf — secure anonymous-only read-only FTP
# Disable anonymous access (enable only if needed)
anonymous_enable=NO
local_enable=YES
write_enable=YES
# Restrict users to their home directories
chroot_local_user=YES
allow_writeable_chroot=YES
# Passive mode port range (open these in firewall)
pasv_min_port=50000
pasv_max_port=50100
# Logging
xferlog_enable=YES
xferlog_std_format=YES
log_ftp_protocol=YES
# Rate limiting (1000 KB/s max)
local_max_rate=1000000
FTPS Configuration (FTP over TLS)
# /etc/vsftpd.conf — add TLS support
ssl_enable=YES
allow_anon_ssl=NO
force_local_data_ssl=YES
force_local_logins_ssl=YES
# TLS certificates (generate or use Let's Encrypt)
rsa_cert_file=/etc/ssl/certs/vsftpd.pem
rsa_private_key_file=/etc/ssl/private/vsftpd.pem
# Require TLS 1.2+ (disable obsolete protocols)
ssl_tlsv1=YES
ssl_sslv2=NO
ssl_sslv3=NO
# vsftpd 3.0.5+ supports explicit TLS 1.2/1.3 directives
ssl_tlsv1_2=YES
ssl_tlsv1_3=YES
# Restrict cipher suites
ssl_ciphers=HIGH
Generate a self-signed certificate for testing:
sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/ssl/private/vsftpd.pem \
-out /etc/ssl/certs/vsftpd.pem \
-subj "/CN=ftp.example.com"
sudo chmod 600 /etc/ssl/private/vsftpd.pem
sudo systemctl restart vsftpd
Firewall Rules
# Open FTP control and passive data ports
sudo ufw allow 21/tcp
sudo ufw allow 50000:50100/tcp
sudo ufw reload
Virtual Users Configuration
Virtual users have no corresponding system accounts, which limits the blast radius of a compromise. They authenticate against a standalone database rather than /etc/passwd.
# Create a local user to map virtual users to
sudo useradd --home /home/vsftpd --gid nogroup -m --shell /bin/false vsftpd
# /etc/vsftpd.conf — virtual user settings
anonymous_enable=NO
local_enable=YES
write_enable=YES
# Virtual user mapping
guest_enable=YES
guest_username=vsftpd
virtual_use_local_privs=YES
# Per-user config directory
user_config_dir=/etc/vsftpd/user_conf
# Chroot all virtual users
chroot_local_user=YES
allow_writeable_chroot=YES
# Passive port range
pasv_min_port=50000
pasv_max_port=50100
# TLS
ssl_enable=YES
force_local_data_ssl=YES
force_local_logins_ssl=YES
rsa_cert_file=/etc/ssl/certs/vsftpd.pem
rsa_private_key_file=/etc/ssl/private/vsftpd.pem
Create the virtual user database:
# Create password file (username on odd lines, password on even)
sudo tee /etc/vsftpd/virtual_users.txt <<EOF
alice
strong-password-1
bob
strong-password-2
EOF
# Generate the database
sudo db_load -T -t hash -f /etc/vsftpd/virtual_users.txt \
/etc/vsftpd/virtual_users.db
sudo chmod 600 /etc/vsftpd/virtual_users.db
Configure PAM to use the database:
# /etc/pam.d/vsftpd
sudo tee /etc/pam.d/vsftpd <<EOF
auth required pam_userdb.so db=/etc/vsftpd/virtual_users
account required pam_userdb.so db=/etc/vsftpd/virtual_users
EOF
Per-user home directories:
# Create per-user config
sudo mkdir -p /etc/vsftpd/user_conf
# Each user gets a config file
echo "local_root=/srv/ftp/alice" | sudo tee /etc/vsftpd/user_conf/alice
echo "local_root=/srv/ftp/bob" | sudo tee /etc/vsftpd/user_conf/bob
sudo mkdir -p /srv/ftp/alice /srv/ftp/bob
sudo chown vsftpd:nogroup /srv/ftp/alice /srv/ftp/bob
Python FTP Client
Plain FTP
The ftplib module provides FTP client functionality. The FTP class manages the control connection, and methods like storbinary and retrbinary handle binary data transfers over the data connection:
from ftplib import FTP
def ftp_upload(filename, host, username, password):
with FTP(host) as ftp:
ftp.login(user=username, passwd=password)
ftp.voidcmd('TYPE I') # Binary mode (TYPE I = Image)
with open(filename, 'rb') as f:
ftp.storbinary(f'STOR {filename}', f)
print(f'Uploaded {filename}')
def ftp_download(filename, host, username, password):
with FTP(host) as ftp:
ftp.login(user=username, passwd=password)
ftp.voidcmd('TYPE I')
with open(filename, 'wb') as f:
ftp.retrbinary(f'RETR {filename}', f.write)
print(f'Downloaded {filename}')
def ftp_list_files(host, username, password):
with FTP(host) as ftp:
ftp.login(user=username, passwd=password)
files = []
ftp.retrlines('LIST', files.append)
for f in files:
print(f)
Directory Operations
from ftplib import FTP
with FTP('ftp.example.com') as ftp:
ftp.login()
ftp.cwd('/pub') # Change directory
ftp.mkd('newdir') # Create directory
ftp.delete('oldfile.txt') # Delete file
ftp.rename('old.txt', 'new.txt') # Rename
size = ftp.size('largefile.zip') # Get file size in bytes
print(f'Size: {size} bytes')
Error Handling
ftplib raises specific exceptions that should be caught in production code:
from ftplib import FTP, error_perm, error_temp, error_reply
def safe_download(host, username, password, remote_file, local_file):
try:
with FTP(host, timeout=30) as ftp:
ftp.login(username, password)
ftp.voidcmd('TYPE I')
with open(local_file, 'wb') as f:
ftp.retrbinary(f'RETR {remote_file}', f.write)
return True
except error_perm as e:
# Permanent error: file not found, permission denied
print(f"Permission error: {e}")
except error_temp as e:
# Temporary error: retry later
print(f"Temporary error, retry: {e}")
except error_reply as e:
# Unexpected reply code
print(f"Unexpected reply: {e}")
except TimeoutError:
print("Connection timed out")
return False
Resuming Interrupted Transfers
The REST command tells the server to skip a number of bytes before starting the transfer, enabling resume:
from ftplib import FTP
import os
def download_with_resume(host, username, password, remote_file, local_file):
offset = os.path.getsize(local_file) if os.path.exists(local_file) else 0
with FTP(host) as ftp:
ftp.login(username, password)
ftp.voidcmd('TYPE I')
if offset > 0:
ftp.rest(offset) # RESTART at byte offset
with open(local_file, 'ab') as f:
ftp.retrbinary(f'RETR {remote_file}', f.write, rest=offset)
print(f'Downloaded {remote_file} (resumed at byte {offset})')
Recursive Directory Download
FTP has no single command to download an entire directory tree. The MLSD command (RFC 3659) returns machine-readable listings that include type information, making recursive traversal straightforward:
from ftplib import FTP
import os
def download_tree(host, username, password, remote_root, local_root):
with FTP(host) as ftp:
ftp.login(username, password)
def recurse(remote_dir, local_dir):
os.makedirs(local_dir, exist_ok=True)
ftp.cwd(remote_dir)
for entry in ftp.mlsd():
name, attrs = entry
if name in ('.', '..'):
continue
if attrs['type'] == 'dir':
recurse(f'{remote_dir}/{name}',
os.path.join(local_dir, name))
else:
local_path = os.path.join(local_dir, name)
with open(local_path, 'wb') as f:
ftp.retrbinary(f'RETR {name}', f.write)
recurse(remote_root, local_root)
# Download entire /pub/docs tree to ./docs
download_tree('ftp.example.com', 'user', 'pass', '/pub/docs', './docs')
FTPS (FTP over TLS)
FTPS adds TLS encryption to FTP. Use FTP_TLS and call prot_p() to enable encrypted data channels:
from ftplib import FTP_TLS
class SecureFTP:
def __init__(self, host, username, password):
self.ftp = FTP_TLS(host)
self.ftp.login(username, password)
self.ftp.prot_p() # Enable data channel encryption (PROT P)
def upload(self, filename):
with open(filename, 'rb') as f:
self.ftp.storbinary(f'STOR {filename}', f)
def download(self, filename):
with open(filename, 'wb') as f:
self.ftp.retrbinary(f'RETR {filename}', f.write)
prot_p() sends the PROT command with argument P (Private), instructing the server to encrypt all data channel transfers. Without this call, only the control channel is encrypted. For explicit FTPS connections where the server certificate is self-signed, pass a context that skips verification:
import ssl
from ftplib import FTP_TLS
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
ftp = FTP_TLS('ftp.example.com', context=context)
SFTP (SSH File Transfer)
SFTP is not FTP over SSH — it is a completely different protocol that uses the SSH transport layer. It requires the paramiko library:
import paramiko
class SFTPClient:
def __init__(self, host, port, username, password=None, key_file=None):
transport = paramiko.Transport((host, port))
if key_file:
key = paramiko.RSAKey.from_private_key_file(key_file)
transport.connect(username=username, pkey=key)
else:
transport.connect(username=username, password=password)
self.sftp = paramiko.SFTPClient.from_transport(transport)
def upload(self, local_path, remote_path):
self.sftp.put(local_path, remote_path)
def download(self, remote_path, local_path):
self.sftp.get(remote_path, local_path)
def list_files(self, path):
return self.sftp.listdir(path)
def close(self):
self.sftp.close()
client = SFTPClient('server.example.com', 22, 'user', password='secret')
client.upload('/local/file.txt', '/remote/file.txt')
client.close()
FTP via curl
The curl command-line tool supports FTP, FTPS, and SFTP, making it useful for scripting:
# List directory over FTP
curl ftp://ftp.example.com --user username:password
# FTP download
curl -O ftp://ftp.example.com/file.zip --user username:password
# FTP upload
curl -T file.zip ftp://ftp.example.com/ --user username:password
# FTPS (explicit) upload
curl --ftp-ssl -T file.zip ftp://ftp.example.com/ --user username:password
# FTPS (implicit) upload
curl --ftp-ssl-reqd -T file.zip ftps://ftp.example.com/ --user username:password
# SFTP upload via SSH
curl -T file.zip sftp://server.example.com/ --user username:password
FTP vs FTPS vs SFTP
| Feature | Plain FTP | FTPS (FTP+SSL) | SFTP (SSH) |
|---|---|---|---|
| Port | 21 (control), dynamic data | 21 or 990 + dynamic data | 22 (single port) |
| Encryption | None (cleartext) | TLS 1.2+ | SSH channel |
| Authentication | Plaintext password | TLS cert + password | SSH key or password |
| Firewall friendly | No (dynamic data ports) | No (dynamic data ports) | Yes (single port) |
| Connection model | Dual connection | Dual connection | Single connection |
| Directory listing | LIST, MLSD | LIST, MLSD (encrypted) | SFTP protocol |
| Transfer resume | REST command | REST command | Built-in |
| Best for | LAN, legacy systems | Controlled environments | Any modern system |
Plain FTP sends credentials and data in cleartext — never use it over the internet. FTPS adds TLS but retains FTP’s dual-connection architecture, which complicates firewalling and NAT traversal. SFTP uses a single SSH connection and is the recommended choice for all new file transfer implementations.
Enterprise Automation Patterns
Scheduled Batch Transfer with Error Reporting
import logging
from ftplib import FTP, error_perm
from datetime import datetime
from pathlib import Path
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler('/var/log/ftp-sync.log'),
logging.StreamHandler()
]
)
def sync_files(host, username, password, remote_dir, local_dir):
local_dir = Path(local_dir)
local_dir.mkdir(parents=True, exist_ok=True)
try:
with FTP(host, timeout=60) as ftp:
ftp.login(username, password)
ftp.cwd(remote_dir)
ftp.voidcmd('TYPE I')
files = []
ftp.retrlines('NLST', files.append)
for filename in files:
local_path = local_dir / filename
with open(local_path, 'wb') as f:
ftp.retrbinary(f'RETR {filename}', f.write)
logging.info(f"Downloaded {filename}")
logging.info(f"Sync completed: {len(files)} files")
return True
except error_perm as e:
logging.error(f"Permanent error during sync: {e}")
except Exception as e:
logging.error(f"Sync failed: {e}")
return False
FTP Health Monitor
import time
from ftplib import FTP
def check_ftp_health(host, port=21, timeout=10):
"""Check FTP server availability and response time."""
start = time.monotonic()
try:
with FTP() as ftp:
ftp.connect(host, port, timeout=timeout)
ftp.voidcmd('NOOP') # No-operation command tests connectivity
elapsed = (time.monotonic() - start) * 1000
return {"status": "ok", "latency_ms": round(elapsed, 1)}
except Exception as e:
return {"status": "error", "message": str(e)}
Security Hardening
# /etc/vsftpd.conf — production security settings
# Disable anonymous access
anonymous_enable=NO
# Chroot local users to their home directory
chroot_local_user=YES
allow_writeable_chroot=YES
# Require TLS for all logins and data
ssl_enable=YES
force_local_logins_ssl=YES
force_local_data_ssl=YES
# Restrict TLS versions (disable obsolete)
ssl_tlsv1=YES
ssl_tlsv1_2=YES
ssl_tlsv1_3=YES
ssl_sslv2=NO
ssl_sslv3=NO
# Restrict ciphers to strong suites
ssl_ciphers=HIGH
# Limit max clients and rate
max_clients=50
max_per_ip=5
local_max_rate=1000000 # 1000 KB/s
# Log all commands and responses
log_ftp_protocol=YES
vsftpd_log_file=/var/log/vsftpd.log
Log Analysis
vsftpd logs every command and response when log_ftp_protocol=YES. Monitor these patterns:
# Failed login attempts
Tue May 20 08:15:30 2026 [pid 1234] [root] FAIL LOGIN: Client "203.0.113.50"
# Anonymous access attempts
Tue May 20 08:20:00 2026 [pid 1235] ANONYMOUS LOGIN (Client "198.51.100.20")
# File access outside chroot (security violation)
Tue May 20 08:25:00 2026 [pid 1236] [user1] FAIL CHROOT: "/etc/passwd"
Use fail2ban to auto-ban IPs with repeated failed logins:
# /etc/fail2ban/jail.d/vsftpd.conf
[vsftpd]
enabled = true
port = ftp,ftp-data
filter = vsftpd
logpath = /var/log/vsftpd.log
maxretry = 3
bantime = 3600
Best Practices
- Never use plain FTP over the internet — use FTPS or SFTP
- Restrict passive port range (e.g., 50000-50100) for tight firewall rules
- Chroot users to their home directories to prevent filesystem escape
- Use virtual users instead of system accounts when possible
- Rate-limit connections per IP to prevent abuse
- Monitor logs for failed logins, chroot violations, and unusual transfer patterns
- Prefer SFTP for all new deployments — single port, single connection, encrypted by default
- For FTPS, use explicit mode (AUTH TLS) over implicit mode
- Generate separate TLS certificates for each server; never reuse self-signed certs across environments
- Schedule automated transfer integrity checks (file count, checksum comparison)
Conclusion
FTP remains relevant in 2026 primarily as infrastructure cargo — millions of hosts run it by default, particularly on shared hosting platforms. The protocol’s dual-connection architecture and cleartext credentials make it unsuitable for internet-facing use without encryption, but FTPS and SFTP provide viable paths forward.
Key recommendations:
- SFTP for all new file transfer implementations
- FTPS (explicit TLS) for environments where SFTP is unavailable and legacy FTP clients must be supported
- Plain FTP for isolated LAN environments only, with no internet exposure
- Use
MLSDfor machine-parsable directory listings,RESTfor transfer resume, andEPSVfor passive mode on IPv6 networks
Related Articles
Resources
- RFC 959 — File Transfer Protocol
- RFC 4217 — FTP over TLS/SSL (FTPS)
- RFC 3659 — Extensions to FTP
- RFC 2428 — FTP Extensions for IPv6 and NATs
- RFC 2228 — FTP Security Extensions
- vsftpd Official Site
- vsftpd.conf Man Page
- Paramiko Documentation
- Python ftplib Documentation
- Censys FTP Exposure Brief (2026)
Comments