Secure File Transfer Protocols for Banks: SFTP, FTPS & AS2 Ingestion
When an ACH or wire confirmation file crosses the boundary between a clearing network and your core banking systems, the transport layer is the first and only place to prove where the bytes came from, that nobody altered them, and that they arrived intact and exactly once. Get it wrong and every downstream stage inherits the damage: an unverified signature lets a spoofed file into the parser, a truncated transfer posts a partial batch, and a missing receipt breaks the chain of custody an examiner will trace during a dispute. This guide covers the three transport protocols banks actually run — SFTP, FTPS, and AS2 — plus the payload encryption, key lifecycle, and memory-safe ingestion patterns that make delivery auditable, sitting within the broader Core Architecture & Payment File Standards framework that governs how this institution ingests, validates, and audits payment files at scale.
Secure file transfer is not merely a networking concern; it is the first control layer in the reconciliation lifecycle. Transport encryption guarantees confidentiality on the wire, but non-repudiation and data-at-rest protection demand a second, payload-level envelope. The concrete retrieval-and-decrypt mechanics for the dominant ACH corridor are documented in Implementing SFTP with PGP for ACH files; once a file is decrypted and its structure verified, the trace numbers and control totals it carries feed the NACHA record layouts parser and, eventually, the transaction matching & reconciliation engine. Signature verification must happen before any parsing runs — a parser that executes on unverified bytes is a parser that can be weaponized by a malformed or forged transmission.
Concept Definition: What "Secure Transfer" Means at the Byte Level
A secure payment-file transfer is a session that provides four guarantees simultaneously: confidentiality (the payload is unreadable on the wire), integrity (any bit-flip is detectable), authentication (both endpoints prove identity), and non-repudiation (a signed receipt proves delivery happened). No single protocol delivers all four at both the transport and application layers, which is why banks pair a transport protocol with a payload envelope. The three transport protocols differ in the exact ports, negotiation model, and audit granularity they expose:
| Protocol | Port(s) | Security layer | Auth model | Delivery receipt | Typical corridor |
|---|---|---|---|---|---|
| SFTP (SSH File Transfer Protocol) | 22 (TCP) | SSH transport (RFC 4253) | Public-key and/or password over one encrypted channel | None native — inferred from session log | Domestic ACH origination/receipt |
| FTPS (FTP over TLS) | 21 explicit / 990 implicit; ephemeral data ports | TLS 1.2+ | X.509 client + server certificates | None native | Legacy correspondent-bank corridors |
| AS2 (Applicability Statement 2) | 80/443 (HTTP/S) | TLS on the wire + S/MIME on the payload | X.509 certificates | Synchronous or async MDN (Message Disposition Notification) | EDI/SWIFT-adjacent wire confirmations |
SFTP runs a single encrypted channel over TCP port 22: control commands and file data share one SSH session, so every open, get, and rename is captured in one deterministic session log. FTPS negotiates TLS over the classic FTP command channel (explicit mode on port 21, implicit on 990) and then opens separate data connections on ephemeral ports — which is exactly why FTPS is fragile behind NAT and stateful firewalls and why its audit trail is split across connections. AS2 wraps the file in an S/MIME envelope, ships it over HTTP/S, and returns a signed MDN receipt: the MDN carries a hash (the "Received-Content-MIC") of the delivered payload, giving cryptographic proof of what was received, not merely that something was. That MDN is the only one of the three that provides application-layer non-repudiation without a separate PGP signature.
The payload envelope is orthogonal to transport. For ACH, that envelope is almost always OpenPGP (RFC 4880): the file is compressed, symmetrically encrypted with a one-time session key, and that session key is itself encrypted to the recipient's public key, with a detached or attached signature packet binding the sender's identity to the plaintext. For wires and ISO 20022 messages, the envelope is more often an X.509/CMS signature backed by an HSM — the format tradeoffs are compared in ISO 20022 vs legacy formats.
Architecture: Where Transport Sits in the Reconciliation Pipeline
The ingestion boundary is deliberately decoupled from parsing. A file lands in a drop directory or arrives as an AS2 POST; the transport handler's only job is to move the ciphertext to a quarantine path, record a SHA-256 fingerprint, and verify the signature. Only after verification succeeds does the plaintext enter the streaming parser. This ordering is not stylistic — it is a security control. If parsing ran first, a malformed positional record could trigger a slicing bug on bytes whose origin was never proven.
Inside that boundary the flow is strictly staged, and every stage writes to an append-only audit ledger rather than mutating a status field in place:
- Arrival — a poll of the SFTP drop directory, or an inbound AS2 request, detects a new object.
- Quarantine + fingerprint — the ciphertext is copied to an isolated path and hashed in a single streaming pass; the digest becomes the immutable ingestion identity.
- Acknowledge — for AS2, a signed MDN is returned; for SFTP, a receipt row is written before decryption begins.
- Verify — the detached or attached signature is checked against a pinned public key with a strict algorithm whitelist.
- Decrypt (streaming) — the payload is decrypted in bounded chunks to a plaintext staging buffer, never loaded whole into RAM.
- Validate + route — structural checks run; clean files advance, malformed files go to a dead-letter queue with their raw payload intact.
The AS2 message flow specifically is a request/receipt handshake worth drawing out, because the MDN is what closes the non-repudiation loop:
Phase-by-Phase Implementation
Payment files routinely exceed 500 MB, so file.read() into a single buffer is a direct cause of OOM kills and pipeline stalls. Every stage below is written as a generator or bounded-chunk loop so that resident memory stays flat regardless of file size, and every cryptographic operation is logged before the plaintext is exposed.
1. Fetch over SFTP and fingerprint in one streaming pass
Use paramiko's SFTPClient to stream the ciphertext to a quarantine path while feeding each chunk into a SHA-256 accumulator. The digest is computed before decryption, so the audit identity is bound to exactly the bytes that crossed the wire. Enforce strict host-key verification (RejectPolicy) so a rotated or spoofed server key aborts the session instead of silently trusting it.
import hashlib
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import TypedDict
import paramiko
logger = logging.getLogger("payment.ingestion")
CHUNK = 1024 * 1024 # 1 MiB: bounds RAM independent of file size
class IngestRecord(TypedDict):
ingested_at: str
remote_path: str
quarantine_path: str
sha256: str
byte_count: int
def fetch_and_fingerprint(
sftp: paramiko.SFTPClient,
remote_path: str,
quarantine_dir: Path,
chunk_size: int = CHUNK,
) -> IngestRecord:
"""Stream an encrypted ACH file off SFTP, hashing as we go.
Memory is O(1): one chunk is resident at a time regardless of the
file size. The SHA-256 fingerprint is computed over the ciphertext,
before any decryption, so it identifies exactly what arrived.
"""
quarantine_dir.mkdir(parents=True, exist_ok=True)
local_path = quarantine_dir / Path(remote_path).name
digest = hashlib.sha256()
total = 0
with sftp.open(remote_path, "rb") as remote, open(local_path, "wb") as out:
remote.prefetch() # pipeline reads so throughput isn't RTT-bound
while chunk := remote.read(chunk_size):
digest.update(chunk)
out.write(chunk)
total += len(chunk)
record: IngestRecord = {
"ingested_at": datetime.now(timezone.utc).isoformat(),
"remote_path": remote_path,
"quarantine_path": str(local_path),
"sha256": digest.hexdigest(),
"byte_count": total,
}
logger.info("fetched %s (%d bytes) sha256=%s",
remote_path, total, record["sha256"])
return record
2. Wrap the connection with bounded retries and backoff
Clearing-network SFTP endpoints drop connections under load, so the fetch must retry idempotently — the fingerprint makes a re-fetch safe to compare against the first attempt. Use exponential backoff with jitter and a hard attempt ceiling so a persistently unreachable counterparty raises rather than spinning forever.
import random
import time
from typing import Callable, TypeVar
T = TypeVar("T")
def with_backoff(
op: Callable[[], T],
*,
attempts: int = 5,
base_delay: float = 1.0,
max_delay: float = 30.0,
) -> T:
"""Retry a transport operation with exponential backoff + jitter.
Raises the last exception once the attempt ceiling is hit, so a dead
counterparty surfaces as a pageable error rather than a silent hang.
"""
last_exc: Exception | None = None
for attempt in range(1, attempts + 1):
try:
return op()
except (paramiko.SSHException, OSError) as exc:
last_exc = exc
if attempt == attempts:
break
delay = min(max_delay, base_delay * 2 ** (attempt - 1))
delay += random.uniform(0, delay * 0.25) # decorrelate retries
logger.warning("transport attempt %d/%d failed: %s; retry in %.1fs",
attempt, attempts, exc, delay)
time.sleep(delay)
assert last_exc is not None
raise last_exc
3. Verify the signature before touching the payload
Verification runs against a pinned public key with an explicit algorithm whitelist. Reject anything signed with a weak hash (MD5, SHA-1) or an unexpected key — a permissive verifier is functionally no verifier at all. This gate returns a boolean the pipeline treats as a hard exception on failure.
from typing import Iterable
import pgpy
ALLOWED_HASHES: frozenset[pgpy.constants.HashAlgorithm] = frozenset({
pgpy.constants.HashAlgorithm.SHA256,
pgpy.constants.HashAlgorithm.SHA384,
pgpy.constants.HashAlgorithm.SHA512,
})
def verify_detached(
plaintext_path: Path,
signature_path: Path,
trusted_keys: Iterable[pgpy.PGPKey],
) -> bool:
"""Verify a detached OpenPGP signature against pinned public keys.
Rejects weak hash algorithms outright: an SHA-1 signature on a
payment file is treated as an unsigned file.
"""
sig = pgpy.PGPSignature.from_file(str(signature_path))
if sig.hash_algorithm not in ALLOWED_HASHES:
logger.error("rejected signature: weak hash %s", sig.hash_algorithm)
return False
message = pgpy.PGPMessage.new(str(plaintext_path), file=True)
for key in trusted_keys:
verification = key.verify(message, sig)
if bool(verification):
logger.info("signature verified against key %s", key.fingerprint)
return True
logger.error("no trusted key verified the signature")
return False
4. Decrypt in bounded chunks and validate structure early
Decryption streams into a plaintext staging buffer, and structural validation runs a cheap first-pass check — record length and leading record-type byte for ACH — so a malformed file is rejected before it reaches the full NACHA record layouts parser or, for XML payloads, the pydantic schema validation for payments layer.
from typing import Iterator
RECORD_LEN = 94 # NACHA fixed-width record
def iter_ach_records(plaintext_path: Path) -> Iterator[str]:
"""Yield 94-byte NACHA records, tolerating LF or CRLF terminators.
Generator keeps memory flat; a short final chunk is a truncation
error, never a silent EOF.
"""
with open(plaintext_path, "rb") as fh:
while chunk := fh.read(RECORD_LEN):
if len(chunk) < RECORD_LEN:
raise ValueError(f"truncated record: {len(chunk)} bytes")
term = fh.read(1)
if term == b"\r":
fh.read(1) # swallow LF of a CRLF pair
yield chunk.decode("ascii", errors="replace")
def first_pass_ok(plaintext_path: Path) -> bool:
"""Reject files whose first record is not a NACHA File Header ('1')."""
for record in iter_ach_records(plaintext_path):
return record[0] == "1"
return False # empty file
5. Route the outcome to an audit-ready queue
Clean files advance to reconciliation; anything that failed verification or validation is quarantined with structured metadata so it can be replayed during a dispute. The routing record is append-only and carries the file hash, failure code, and timestamp — never a mutable status flag.
import json
from typing import Optional
def route_ingestion(
record: IngestRecord,
status: str,
error_code: Optional[str] = None,
) -> dict[str, str]:
"""Emit an immutable routing decision. status ∈ {'PASS','REJECT'}."""
decision = {
"decided_at": datetime.now(timezone.utc).isoformat(),
"file_hash": record["sha256"],
"status": status,
"error_code": error_code or "",
"destination": "reconciliation_engine" if status == "PASS"
else "exception_dlq",
}
if status == "PASS":
logger.info("cleared for reconciliation: %s", record["sha256"])
else:
logger.warning("pre-reconciliation exception: %s",
json.dumps(decision))
# publish to Kafka / SQS / S3 exception bucket, ciphertext retained
return decision
For multi-million-record files, hand the accepted-entry stream to the vectorised high-volume pandas parsing strategies rather than looping in pure Python, and run the whole ingestion under the concurrency model described in async batch processing architectures.
Key Lifecycle & Cryptographic Controls
Transport keys and payload keys have different lifetimes and different blast radii, and conflating them is a recurring audit finding. Manage them as three separate populations:
- Transport keys — SSH host and client keys (SFTP) and TLS certificates (FTPS/AS2). Rotate on a fixed schedule (quarterly is typical) through a centralized secret store such as HashiCorp Vault or a cloud KMS, with automated revocation on compromise. Pin counterparty host keys so rotation is a deliberate, logged event rather than a trust-on-first-use gamble.
- Payload keys — OpenPGP asymmetric pairs (4096-bit RSA or Curve25519) or X.509 signing certificates. Private keys live in a FIPS 140-3 validated HSM or a KMS with strict IAM boundaries and never reside on an application server or in a container environment variable. The application requests a decrypt or sign operation from the boundary; it does not hold the key.
- Session keys — the one-time symmetric keys OpenPGP generates per file. These are ephemeral by construction; the only discipline required is that the library discards them after use and that logging never captures them.
Signature verification uses a strict algorithm whitelist — SHA-256/384/512 for digests, RSA-PSS or Ed25519 for signatures — and rejects the deprecated ciphers that RFC 4880 still technically permits. The whitelist lives in code (see step 3) so a downgrade attack cannot slip a SHA-1 signature past the gate.
Edge Cases & Known Failure Modes
| Failure scenario | Root cause | Mitigation |
|---|---|---|
| Silent OOM on a 900 MB file | sftp.get() or read() loads the whole payload into RAM |
Stream in fixed chunks; hash and write incrementally |
| FTPS transfer hangs behind NAT | Data connection opens on an ephemeral port the firewall blocks | Prefer SFTP's single channel; if FTPS is mandated, pin a passive-port range and open it explicitly |
| Spoofed file accepted | Signature verified after parsing, or verifier accepts any key | Verify before parse; pin trusted keys and whitelist hash algorithms |
| Duplicate posting after retry | Re-fetch reprocesses a file already ingested | Deduplicate on the SHA-256 fingerprint; make routing idempotent per hash |
| Host-key change breaks the run | Counterparty rotated its SSH key without notice | RejectPolicy + pinned known-hosts; treat a mismatch as a paged exception, not auto-trust |
| AS2 message marked failed but delivered | MDN lost in transit; sender never saw the receipt | Support asynchronous MDN with retry; reconcile on the Received-Content-MIC, not on connection state |
| Truncated final record | Transfer cut short; last chunk < 94 bytes | Raise on short read; never pad to length silently |
| Weak-cipher signature slips through | Verifier trusts whatever algorithm the packet declares | Enforce an explicit ALLOWED_HASHES set; reject MD5/SHA-1 |
| Plaintext left on disk | Decrypt-to-file then parse | Decrypt to an in-memory or short-TTL staging buffer; shred plaintext after routing |
Compliance & Auditability
Secure transfer sits inside a dense regulatory perimeter, and each control maps to a specific obligation. The FFIEC IT Examination Handbook (Information Security and Operations booklets) expects documented encryption in transit and at rest, key-management separation of duties, and an unbroken chain of custody from receipt to settlement — which is why the SHA-256 fingerprint is written before decryption and why AS2 returns a signed MDN. Because most inbound ACH entries are consumer debits and credits, the pipeline is inside the scope of Regulation E (12 CFR 1005); §1005.11 sets the error-resolution timeline, and satisfying it requires that every rejected file be reconstructable — hence the immutable routing record with hash, failure code, and timestamp. Aggregate ACH settlement reconciles back to the Federal Reserve under its ACH Operating Circular, so a file that fails integrity verification is a hard stop, never a warning that gets logged and swallowed.
Data-handling rules from PCI-DSS and the GLBA Safeguards Rule govern what the logs may contain: log cryptographic metadata — key fingerprint, decrypt timestamp, file hash — at INFO, but mask key material and plaintext payloads even at DEBUG. Retention follows the strictest applicable clock: keep transfer logs, MDN receipts, decryption audit trails, and failed-authentication events for seven years so an examiner can trace any single file end to end.
Testing & Verification
Test the transport layer against fixtures whose hash and structure you control, and assert both the happy path and the security guardrails — especially that verification actually rejects what it should.
import hashlib
from pathlib import Path
import pytest
def test_fingerprint_matches_known_bytes(tmp_path: Path):
payload = b"1" + b" " * 93 + b"\n" # minimal File Header record
src = tmp_path / "in.ach"
src.write_bytes(payload)
expected = hashlib.sha256(payload).hexdigest()
# stream-hash the same way the fetcher does
digest = hashlib.sha256()
with open(src, "rb") as fh:
while chunk := fh.read(8):
digest.update(chunk)
assert digest.hexdigest() == expected
def test_first_pass_rejects_non_header(tmp_path: Path):
bad = tmp_path / "bad.ach"
bad.write_bytes(b"6" + b" " * 93 + b"\n") # starts with Entry Detail
assert first_pass_ok(bad) is False
def test_truncated_record_raises(tmp_path: Path):
short = tmp_path / "short.ach"
short.write_bytes(b"1" + b" " * 40) # < 94 bytes
with pytest.raises(ValueError, match="truncated"):
list(iter_ach_records(short))
def test_backoff_reraises_after_ceiling():
calls = {"n": 0}
def always_fails():
calls["n"] += 1
raise OSError("connection reset")
with pytest.raises(OSError):
with_backoff(always_fails, attempts=3, base_delay=0.0)
assert calls["n"] == 3 # exactly the ceiling, no infinite loop
A structured fixture pins the expected routing decision for a known-good and a known-bad file so regressions surface immediately:
{
"cases": [
{
"file": "good-ppd.ach.pgp",
"signature_valid": true,
"first_pass_ok": true,
"expected_status": "PASS",
"expected_destination": "reconciliation_engine"
},
{
"file": "spoofed-ppd.ach.pgp",
"signature_valid": false,
"first_pass_ok": true,
"expected_status": "REJECT",
"expected_error_code": "SIGNATURE_UNVERIFIED"
}
]
}
Frequently Asked Questions
Is SFTP the same thing as FTPS?
No — they share almost nothing but a similar name. SFTP is a file-transfer subsystem of SSH: it runs one encrypted channel over TCP port 22, and control commands plus file data share that single session. FTPS is the legacy FTP protocol wrapped in TLS: it keeps FTP's split-channel design, negotiating security on the command channel (port 21 explicit, 990 implicit) and then opening separate data connections on ephemeral ports. That split is why FTPS is fragile behind NAT and firewalls and why its audit trail is scattered across connections. For domestic ACH, SFTP's single auditable session is the reason it is the default.
When would a bank use AS2 instead of SFTP?
AS2 earns its place where application-layer non-repudiation is a hard requirement — typically EDI-heavy and SWIFT-adjacent wire confirmations. Its synchronous or asynchronous MDN returns a signed receipt containing a hash (the Received-Content-MIC) of the delivered payload, so the sender has cryptographic proof of exactly what the receiver got, not merely that a connection succeeded. SFTP gives you a session log; AS2 gives you a portable, signed receipt you can present in a dispute without access to the counterparty's systems.
Why verify the signature before parsing instead of after?
Because a parser that runs on unverified bytes is an attack surface. Fixed-width and XML parsers slice, decode, and allocate based on the file's own contents; a malformed or forged payload can trigger a slicing bug, an oversized allocation, or a poisoned field before you have any proof of where the bytes came from. Verifying the detached signature against a pinned key first means the parser only ever executes on bytes whose origin and integrity are already established. It also keeps the audit story clean: the hash you fingerprinted is the hash you verified is the hash you parsed.
How do I ingest a 1 GB payment file without exhausting memory?
Never call file.read() or sftp.get() in a way that materializes the whole payload. Stream the ciphertext off the wire in fixed chunks (1 MiB is a good default), updating the SHA-256 accumulator and writing to quarantine as you go, then decrypt in bounded blocks and hand the plaintext to a generator that yields one record at a time. That keeps resident memory at O(1) regardless of file size. Only aggregation stages should touch a columnar frame, and even then you stream accepted entries into it rather than building a giant Python list.
What belongs in the transfer logs, and what must never be logged?
Log cryptographic metadata — key fingerprint, decryption timestamp, file SHA-256, byte count, MDN receipt id, and the routing decision — at INFO. Never log key material, passphrases, session keys, or plaintext payload bytes, even at DEBUG; PCI-DSS and the GLBA Safeguards Rule treat that as a data-handling failure. Keep the logs append-only with cryptographic chaining so an examiner can verify the record was not edited after the fact, and retain them for seven years alongside failed-authentication attempts and MDN receipts.
How often should transport and payload keys rotate?
Treat them as separate populations with separate clocks. Transport keys (SSH host/client keys, TLS certificates) rotate on a fixed schedule — quarterly is common — through a centralized vault with automated revocation on compromise, and counterparty host keys are pinned so a rotation is a deliberate, logged event. Payload keys (PGP pairs, X.509 signing certs) live in an HSM or KMS and rotate on the schedule your key-management policy defines, with private material never leaving the boundary. Session keys are per-file and ephemeral; the only rule there is that the library discards them and logging never captures them.
Related on this hub
- Core Architecture & Payment File Standards — the parent reference architecture for ingesting, validating, and auditing payment files.
- Implementing SFTP with PGP for ACH Files — the concrete automated retrieval and PGP decryption pipeline for the ACH corridor.
- NACHA Record Layouts Explained — the byte-level parser that runs once a file is decrypted and verified.
- ISO 20022 vs Legacy Formats — how the payload envelope and parsing model differ for XML wire messaging.
- Async Batch Processing Architectures — the concurrency model that runs many secure ingestions in parallel without starving the event loop.