How to Encrypt a Card Number From the Edge to the Processor

December 18, 2028 · 14 min read

Security · SCS-C03 · part of The Exam Room

The situation

A payments-capable web app, structure:

Browser -> CloudFront -> ALB -> ECS (web) -> Internal fraud service -> External payments processor

The checkout form POSTs JSON with {pan, cvc, name, email, billingAddress, ...} to /checkout. Every downstream component currently sees all fields in plaintext. The compliance impact is:

  • PCI scope. Every component that processes cardholder data is in PCI scope. The web tier, the fraud service, the APM agent, application logs, and the ECS task definitions are all in scope because they potentially see the PAN. In-scope components need PCI DSS controls applied.
  • Leak surface. A bug in the web tier that inadvertently logs the request body leaks cardholder data. A misconfigured APM agent captures cardholder data in span attributes. A compromised ECS task has access to cardholder data.

The desired end state: only the payments processor sees the PAN and CVC in plaintext. Every other component sees ciphertext that they cannot decrypt. PCI scope narrows to just the edge (CloudFront) and the processor; the web tier and fraud service are out of scope for PAN.

CloudFront Field-Level Encryption (FLE) is the AWS feature for this.

What actually matters

TLS is point-to-point encryption between hops; it protects nothing once the connection terminates. Every time a TLS connection ends (at the edge, at the ALB, at the application) the data is in plaintext memory on that component. Any component that holds plaintext is in scope. Narrowing scope means ensuring components in the middle of the chain can’t decrypt the data in the first place.

Field-level encryption does this by encrypting specific fields in the request body at the very first hop the client’s request reaches (CloudFront’s edge) with a public key whose private counterpart is held only by the component that legitimately needs to read it. The fields the customer doesn’t want to leak are encrypted before any other component sees them. The other fields (non-sensitive metadata, routing info, identifiers) stay in plaintext so the rest of the pipeline can still operate.

Field-level encryption is not a replacement for TLS; it’s an additional layer. TLS still wraps the whole request on the wire. FLE encrypts specific fields within the TLS-wrapped body so that when TLS terminates, the sensitive fields remain ciphertext inside the body.

The cryptographic shape is straightforward: an asymmetric algorithm where a public key is held by the edge and the corresponding private key stays with the component that needs to decrypt. The edge processes each incoming request: it parses the body, finds the configured fields, encrypts each one’s value with the public key, and reinserts the ciphertext into the body. The request continues through the pipeline with the sensitive fields now as ciphertext strings.

The component holding the private key decrypts the fields when it receives them. It’s the only component with the key; everything in between (excluding edge caches for sensitive requests, the load balancer, the web tier, the fraud service) either doesn’t handle those fields at all, or treats them as opaque ciphertext strings.

What we’ll filter on

  1. Encryption scope. Whole body, specific fields, or nothing?
  2. Key custody. Where does the private key live?
  3. Algorithm and key length. RSA-OAEP 2048, 4096, other?
  4. Body format support. JSON, form-url-encoded, multipart?
  5. PCI scope reduction. Does it take middle components out of scope?

The field-protection landscape

1. CloudFront Field-Level Encryption (FLE). Edge-level field encryption. Public key uploaded to CloudFront; sensitive fields encrypted at the edge; ciphertext flows through the rest of the pipeline. Algorithm: RSA-OAEP-SHA-256. Key length: 2048-bit (and some higher). Supports application/x-www-form-urlencoded and application/json body formats. Free to configure; no per-request charge beyond normal CloudFront.

2. Application-level field encryption in the web tier. The web tier itself encrypts fields before passing them downstream. Works, but the web tier is still in scope because it holds plaintext briefly. Moves the boundary one hop downstream, doesn’t remove it.

3. Payments tokenisation services (Stripe.js, Braintree Hosted Fields, etc.). The browser sends the cardholder data directly to the payments processor’s servers (via the processor’s JavaScript SDK), which returns a token; the token is posted to the merchant’s backend. The merchant never sees the PAN at all. Widely used in real payments integrations, frequently the correct answer when the merchant is integrating with a PCI-Level-1 processor that provides this SDK. Not CloudFront-native; implemented by the merchant’s front-end code.

4. End-to-end encryption protocols (mTLS-to-processor, VPN to processor’s VPC). Protect the transport from edge to processor but still leave plaintext at the edge and at any intermediate hop that terminates the protocol.

5. Application-level tokenisation with AWS Payment Cryptography. AWS Payment Cryptography is a managed HSM-based service for issuing and validating payment tokens. Complementary to FLE; used inside the payments processor or merchant to handle tokens, not for edge encryption.

Side by side

Option Scope Key custody Algorithm Body formats PCI scope impact
CloudFront FLE Specific fields Merchant (uploaded pub, held private) RSA-OAEP-SHA-256 JSON, form-urlencoded Narrows to edge + processor
App-level encryption Specific fields Merchant Any Any Web tier stays in scope
Processor-provided SDK (Stripe.js etc.) Entire PAN Processor only Processor-defined N/A (browser-direct) Merchant fully out of scope
mTLS to processor Full body Both endpoints TLS N/A Edge + hops still in scope
AWS Payment Cryptography Tokens HSM-managed Payment-specific N/A Complementary

Reading the table: Processor-provided SDKs are the narrowest scope (the merchant never sees the PAN at all). CloudFront FLE is the answer when the merchant must receive the PAN but wants to minimise the components that process it in plaintext.

Where the ciphertext forms, and where it thaws

Browser HTTPS form POST all fields plain PAN plain CVC plain CloudFront edge FLE applies here PAN & CVC → ciphertext PAN ciphertext CVC ciphertext ALB TLS terminated sees ciphertext only PAN ciphertext CVC ciphertext Web tier app + APM agent never holds plaintext PAN ciphertext CVC ciphertext Fraud service reads name, email, billing only PAN ciphertext CVC ciphertext Processor holds private key decrypts → plain PAN plain CVC plain PCI IN SCOPE = holds PAN plaintext OUT OF SCOPE for PAN = sees only ciphertext Non-sensitive fields stay plaintext throughout email, billingAddress, orderId, currency, amount: these flow in plain so routing, fraud scoring, logging, and analytics all work Only the fields named in the FLE content-type profile get encrypted; everything else is untouched CloudFront does NOT cache requests that match an FLE profile; every request goes to origin
PAN and CVC are plaintext only at the edges (browser and processor) and ciphertext through every middle hop; other fields flow plain so the pipeline still works.

The picks in depth

Key management. Generate an RSA 2048-bit key pair offline. Upload the public key to CloudFront as a public key (via aws cloudfront create-public-key). Store the private key on the payments processor’s side, ideally in a CloudHSM cluster or a KMS custom key store in the processor’s account, with access controlled to the decrypt handler only. The public key CloudFront holds is metadata; compromise of the public key is not a security issue because it’s public. The private key is the sensitive artefact; guard it like the root of a PKI.

FLE configuration. Create a field-level encryption profile that names the public key and lists the fields to encrypt:

{
  "Name": "checkout-pan-cvc",
  "CallerReference": "2028-08-20",
  "EncryptionEntities": {
    "Items": [{
      "PublicKeyId": "K2JCJMD3X…",
      "ProviderId": "payments-processor-v1",
      "FieldPatterns": {
        "Items": ["pan", "cvc"]
      }
    }]
  }
}

Then create a field-level encryption configuration that references the profile and maps content types:

{
  "Name": "checkout-config",
  "CallerReference": "2028-08-20",
  "ContentTypeProfileConfig": {
    "ForwardWhenContentTypeIsUnknown": false,
    "ContentTypeProfiles": {
      "Items": [{
        "Format": "URLEncoded",
        "ProfileId": "<profile-id>",
        "ContentType": "application/x-www-form-urlencoded"
      }, {
        "Format": "URLEncoded",
        "ProfileId": "<profile-id>",
        "ContentType": "application/json"
      }]
    }
  }
}

Attach the configuration to the specific cache behavior (the one matching /checkout*). FLE only applies to that behaviour’s requests.

Origin handling. The origin (ALB → web tier) receives the request with pan and cvc as base64-encoded RSA-OAEP ciphertext strings. The web tier doesn’t decrypt; it treats them as opaque strings and forwards to the payments processor. The processor (which might be an external service or an internal bounded context in an isolated account) holds the private key, decrypts the fields, processes the transaction, returns a token. The web tier and fraud service never see the plaintext.

Non-sensitive fields flow unchanged. Email, name, billing address, order ID, currency, and amount aren’t in the encryption profile, so CloudFront passes them through in plaintext. The fraud service reads them to score the transaction; the web tier uses them for logging and receipts. Nothing in the non-sensitive path needs to know FLE exists.

A worked request

Browser submits the checkout form:

POST /checkout HTTP/1.1
Content-Type: application/x-www-form-urlencoded

pan=4111111111111111&cvc=123&name=Alice&email=alice@example.com&amount=19.99

CloudFront receives, matches the /checkout* cache behaviour, matches the content type to the FLE profile, encrypts pan and cvc with the uploaded public key, base64-encodes the ciphertexts:

pan=AQEB...base64...==&cvc=AQEB...base64...==&name=Alice&email=alice@example.com&amount=19.99

ALB receives, TLS-terminates, forwards to web tier. Web tier parses the form, reads email/amount/name for session and logging, serialises the full form (including the encrypted pan/cvc) into a POST to the payments processor. Processor receives, reads the encrypted fields, decrypts with its private key, charges the card, returns a token. Web tier stores the token in the order record; responds to the browser with a thank-you page.

At no point between the edge and the processor did any component hold the PAN or CVC in plaintext.

A worked audit

The PCI auditor asks “demonstrate that the web tier is out of scope for PAN.” The evidence:

  1. CloudFront FLE configuration showing pan and cvc are in the encryption profile for the /checkout* behaviour.
  2. CloudTrail CreatePublicKey event showing the public key was uploaded on a specific date from a specific admin role.
  3. Application logs from the web tier during a test transaction, showing the encrypted blob for pan (e.g. AQEB...==) in the request body but no plaintext PAN anywhere in the log. Grep the logs for the known test PAN; no results.
  4. APM span data showing the encrypted blob, not plaintext.
  5. Runbook demonstrating the private key lives in the processor’s CloudHSM cluster, with a separate audit trail for key-use.

Scope reduction argument: the web tier handles only ciphertext; the ciphertext is useless without the private key which the web tier does not have access to; therefore the web tier does not process cardholder data.

What’s worth remembering

  1. CloudFront Field-Level Encryption encrypts specific POST fields at the edge. RSA-OAEP-SHA-256 with a customer-uploaded public key; the private key stays with the decryption endpoint.
  2. Configuration is in two parts: profile and configuration. The profile names fields + public key; the configuration maps content types + profile to cache behaviours.
  3. Only application/json and application/x-www-form-urlencoded body formats are supported. Multipart uploads are not field-level encrypted by FLE.
  4. FLE disables CloudFront caching on matched requests. Sensitive POSTs are never cacheable anyway; FLE enforces it.
  5. FLE does not replace TLS. TLS still wraps the whole request; FLE encrypts specific fields within the body.
  6. Private-key custody is the critical decision. HSM-held private key for PCI workloads; strict access controls; audit on key-use.
  7. Processor-provided SDKs (Stripe.js, Braintree, etc.) are often the simpler path. If the processor offers a client-side tokenisation library, it removes the merchant from PCI scope entirely, a stronger reduction than FLE.
  8. Non-sensitive fields stay plaintext through the pipeline. Email, billing address, order ID flow in plain so downstream services can continue to function.

Encrypt the field, not the request. Decrypt only where necessary. The middle of the stack holds ciphertext it cannot read, and the PCI scope follows the plaintext.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.