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.

Secure file-transfer ingestion boundary from counterparty to reconciliation engine A horizontal pipeline. A Counterparty box feeds a Transport box (SFTP:22 / FTPS:21 / AS2:443). A dashed trust-boundary frame encloses four stages in order — Quarantine + SHA-256 fingerprint, Verify PGP/CMS signature, streaming Decrypt, and structural Validate — labelled "verify before parse". Solid teal arrows advance a clean file left to right into a Reconciliation engine box. Dashed amber arrows drop from the Verify and Validate stages into a single Exception DLQ box below, which retains the raw ciphertext for replay. A blue return arrow carries a signed MDN / acknowledgement receipt from Transport back up to the Counterparty. Every stage appends to an immutable audit ledger — status is never mutated in place signed MDN / ack receipt Trust boundary — signature proven before any parse runs Counterparty ACH operator · correspondent · SWIFT Transport SFTP · FTPS · AS2 ports 22 · 21 · 443 Quarantine isolate ciphertext → SHA-256 fingerprint Verify PGP / CMS signature pinned key · allowlist Decrypt streaming, bounded chunks — flat RAM Validate structural first-pass check Reconcile matching engine fail fail Exception DLQ raw ciphertext preserved replayable in a dispute

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:

  1. Arrival — a poll of the SFTP drop directory, or an inbound AS2 request, detects a new object.
  2. Quarantine + fingerprint — the ciphertext is copied to an isolated path and hashed in a single streaming pass; the digest becomes the immutable ingestion identity.
  3. Acknowledge — for AS2, a signed MDN is returned; for SFTP, a receipt row is written before decryption begins.
  4. Verify — the detached or attached signature is checked against a pinned public key with a strict algorithm whitelist.
  5. Decrypt (streaming) — the payload is decrypted in bounded chunks to a plaintext staging buffer, never loaded whole into RAM.
  6. 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:

AS2 exchange: signed message, MIC recomputation, and signed-MDN receipt A two-lifeline sequence diagram. The Sender bank lifeline is on the left, the Receiving bank lifeline on the right. A note on the Sender computes the MIC over the S/MIME payload and signs plus encrypts it. A solid teal arrow POSTs the signed and encrypted message over HTTPS to the Receiver. A note on the Receiver decrypts, verifies the signature, and recomputes the MIC. A blue arrow returns a signed MDN echoing the Received-Content-MIC to the Sender, annotated as non-repudiation of receipt because the recomputed MIC matches. A dashed amber arrow shows the Sender retransmitting when no MDN arrives before the timeout. Sender bank Receiving bank 1 · compute MIC over S/MIME payload sign, then encrypt to recipient key 2 · POST signed + encrypted message over HTTPS 3 · decrypt · verify signature recompute MIC on plaintext 4 · signed MDN — echoes Received-Content-MIC MIC matches ⇒ non-repudiation of receipt timeout · no MDN → retransmit (async MDN supported)

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.

python
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.

python
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.

python
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.

python
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.

python
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:

  1. 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.
  2. 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.
  3. 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.

python
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:

json
{
  "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.