Skip to main content

FTP Protocol: File Transfer 2026 — Complete Guide with Python, vsftpd, and Security

Created: March 11, 2026 Larry Qu 15 min read

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 MLSD for machine-parsable directory listings, REST for transfer resume, and EPSV for passive mode on IPv6 networks

Resources

Comments

👍 Was this article helpful?