X.509 Encryption Service
Most engineers consume PKI: they accept a server certificate, trust the TLS handshake and move on. I wanted to understand it from the inside, so I built the smallest system that exercises every layer: a script that creates a certificate authority and signs a server certificate with it, and a serverless service that uses that certificate's key pair for application-layer encryption on top of TLS. It's live as a public demo.
The service exposes two endpoints, /api/encrypt and /api/decrypt, on Netlify Functions. The interesting parts are below the surface: issuing the X.509 certificates, choosing the padding, and getting ESM code to bundle cleanly into a serverless target.
Architecture
│ POST /api/encrypt { "data": … }
├──► public key from server cert
└──► RSA-OAEP encrypt ──► base64
[ /api/decrypt ]
├──► server private key
└──► RSA-OAEP decrypt ──► plaintext
└──► signs [ Server certificate ]
└──► [ RSA key pair ]
Throwaway demo keys · shipped with
the function · trusted nowhere
Why Build It
I built this to understand X.509 well enough to read a certificate without a parser: which OIDs map to which fields, why OAEP is preferred over PKCS#1 v1.5 padding, and what serverless bundlers do to ESM imports under the hood. The fastest way to learn that was to build the smallest system that touches every layer.
Public key infrastructure from scratch
A Node script generates a CA with a 2048-bit RSA key pair, marks it as a CA with the Basic Constraints extension, self-signs it, and uses it to sign a server certificate with SHA-256. The service encrypts with the public key from that certificate and decrypts with the matching private key. Building the CA and its certificate by hand made the chain-validation logic in the TLS Certificate Chain Inspector click in a way that reading the spec hadn't.
OAEP, not PKCS#1 v1.5
RSA encryption needs padding. The classic option is PKCS#1 v1.5; the modern one is OAEP (Optimal Asymmetric Encryption Padding). PKCS#1 v1.5 encryption is open to padding-oracle attacks: Bleichenbacher's attack and its many variants showed that an adversary who can tell decryption errors apart can recover plaintext. OAEP was designed to resist that class of attack.
ESM in a bundled serverless target
Netlify Functions historically ran CommonJS. This code is ESM (.mjs files, import / export, "type": "module" in package.json) and is bundled with esbuild. Bundling ESM for a serverless target surfaces subtle problems: import.meta.url resolves differently in local development than in bundled output, __dirname doesn't exist in ESM, and node-forge's CommonJS exports need the right import shape.
A small path-resolution shim works in both contexts, node-forge loads through the bundler without require() escape hatches, and the certificate and key files ship inside the function bundle rather than in environment variables, which AWS Lambda (and so Netlify Functions) caps at 4 KB in total across all variables.
API
Encrypt
POST /api/encrypt
Request: { "data": "hello world" }
Response: { "originalData": "hello world", "encryptedData": "<base64 RSA-OAEP ciphertext>", … }
Decrypt
POST /api/decrypt
Request: { "encryptedData": "<base64 from /api/encrypt>" }
Response: { "encryptedData": "…", "decryptedData": "hello world", … }
Stateless: the server remembers nothing between calls.
Limitations Acknowledged
This is a teaching artefact, not production cryptography for a real workload:
- RSA encrypts small payloads only. With a 2048-bit key, OAEP fits only a couple of hundred bytes per message. Real systems use hybrid encryption: RSA-OAEP wraps a random symmetric key and AES encrypts the payload; that is the natural next step for this demo. Here I deliberately kept it simple to focus on PKI.
- Demo keys, not secrets. The CA and server keys are throwaway teaching material, deployed with the function rather than kept in a key-management service, and the CA is trusted nowhere: no browser, operating system or device trust store includes it. Don't send anything sensitive to the live demo. A real deployment keeps private keys in a KMS or HSM, never in a code bundle or a repository.
- The chain isn't complete, and it isn't publicly trusted. The leaf is signed by the CA's key, but its issuer name still has to be filled in before standard chain building (
openssl verify) links it to the CA; a real system also needs a CA its clients already trust.
What I Took From It
Three things this project taught me concretely, beyond what reading the specs would have:
- Reading X.509 by eye: which OIDs are which, what the Basic Constraints extension does, and why Subject and Issuer matter to chain validation.
- Why current guidance prefers OAEP for RSA encryption, and why "PKCS#1 v1.5 is fine for our use case" should make a security reviewer pause.
- The mechanics of bundling ESM Node code into serverless targets: a problem many engineers hit and few document, which later serverless projects benefited from solving once.
Try it: encryption.yaqoobahmed.com (opens in new tab). The encrypt and decrypt endpoints round-trip in the browser.