Secrets Exchanger: Sharing Passwords Without Trusting the Server (Including Me)

Secrets Exchanger: Sharing Passwords Without Trusting the Server (Including Me)

11 min read

Every team I’ve ever worked on has had the same problem at least once a month: somebody needs to send somebody else a password, an API key, or a one-off recovery code. Email is awful. Slack is searchable forever. WhatsApp drags personal devices into compliance scope. So people invent something — a Confluence page that “we’ll delete later”, a Bitwarden send link, a Pastebin with a clever filename.

I built Secrets Exchanger as my own version of that pattern: paste a secret, get a single-use link that destroys itself after one read or 24 hours, whichever comes first.

A locked envelope being slid across a counter, then bursting into flames after one read

The interesting bit isn’t the UI — Angular Material does most of that work — but the trick that makes the architecture honest: the server I run can’t read your secret either, and not because I promise. Because the data it stores is mathematically useless without a key it never sees twice.

The full code lives in the secrets-exchanger repo; the live deployment is at secrets-exchanger.andi-john-dev.de.

What “Zero-Knowledge-ish” Actually Means

Plenty of “secure secret sharing” tools claim “zero-knowledge architecture” in their README — and then proudly explain they encrypt your data with a server-side key. That’s not zero-knowledge, that’s trust me bro with extra steps.

I wanted something I could deploy to my own AWS account and still tell a recipient honestly: “if I dumped my entire DynamoDB table right now and handed it to a stranger, they’d have nothing useful.”

The version I shipped is genuinely end-to-end encrypted: the browser encrypts the secret with AES-256-GCM before anything leaves the device, and the server never sees plaintext at any point. The AES key is embedded in the URL fragment only — browsers don’t include the fragment in HTTP requests, so the key physically cannot reach the server. That’s the property the rest of the post is about.

Architecture at a Glance

The whole thing is intentionally small — three CDK stacks, two Lambda handlers, one Angular app:

flowchart LR
    user["Sender / Recipient<br/>(browser)"]
    cf["CloudFront<br/>(Angular SPA)"]
    api["API Gateway<br/>/encrypt · /decrypt"]
    enc["Lambda<br/>store-encrypted-secret"]
    dec["Lambda<br/>read-secret"]
    kms[("AWS KMS<br/>SecretsExchangerKey")]
    ddb[("DynamoDB<br/>secrets-table<br/>TTL = 24h")]

    user --> cf
    user -- "POST /encrypt<br/>POST /decrypt" --> api
    api --> enc
    api --> dec
    enc -- "KMS-wrap { secretId }" --> kms
    enc -- "Put(encryptedData)" --> ddb
    dec -- "KMS-unwrap token" --> kms
    dec -- "Get + Delete" --> ddb
  • Frontend — Angular 21 SPA on S3 + CloudFront. Two screens (create and read), one HTTP service with two methods.
  • API — API Gateway REST API, two Lambda integrations, JSON Schema request validation.
  • State — A single DynamoDB table (secretId partition key, ttl attribute) and one symmetric KMS key.

No auth, no users, no sessions. The “session” is a single ciphertext that travels in a URL.

The Encryption Trick

Here’s the part worth slowing down for. A naive design would do plain envelope encryption: store the secret encrypted with KMS, hand the user a secretId. That works, but it means anyone with read access to my DynamoDB table and KMS decrypt permission (i.e. me, or any IAM principal I’ve ever fat-fingered a policy for) can read every secret in the system. That’s not the property I wanted.

So the design splits the secret into three pieces that never live together:

sequenceDiagram
    autonumber
    participant U as Sender<br/>(browser)
    participant L1 as Lambda
    participant K as KMS
    participant D as DynamoDB
    participant R as Recipient<br/>(browser)

    U->>U: AES-256-GCM encrypt({ secretString, passphrase })<br/>fresh key + 96-bit nonce
    Note over U: Key never leaves the browser
    U->>L1: POST /encrypt { encryptedData: ciphertext }
    L1->>D: Put { secretId, encryptedData, ttl=24h }
    L1->>K: KMS-wrap { secretId }
    K-->>L1: kmsToken
    L1-->>U: 201 { encryptedResponse: kmsToken }
    Note over U,R: URL = ?encryptedInput=kmsToken#key=aesKey<br/>Fragment (#key) is never sent to the server
    R->>L1: POST /decrypt { encryptedInput: kmsToken }
    L1->>K: KMS-unwrap kmsToken
    K-->>L1: { secretId }
    L1->>D: Get + Delete { secretId }
    L1-->>R: 200 { encryptedData: ciphertext }
    R->>R: AES-GCM decrypt with #key from fragment
    R->>R: Parse JSON → verify passphrase client-side

The two halves are:

  1. The ciphertext, sitting in DynamoDB. Browser-encrypted with AES-256-GCM. Neither the Lambda nor DynamoDB has ever seen the plaintext — the server receives and stores an opaque blob.
  2. The AES key, sitting in the URL fragment. The #key= part of the URL is never included in HTTP requests (that’s the whole point of the fragment). KMS wraps only the secretId — it never touches the encryption key.

Neither piece alone is enough. The DynamoDB row is a useless blob without the key. The KMS token in the query string is a pointer to nothing without the row. And the key in the fragment is useless without both. You need all three together, and only the recipient ever has them — because the full URL (including its fragment) travels through whatever channel the sender chose, not through anything I host.

Why AES in the Browser Instead of Just KMS?

KMS could encrypt the plaintext directly and hand back a ciphertext to store. So why do AES in the browser at all?

  • The AES key never reaches the server. If the Lambda called kms.Encrypt(secretString) and stored the result, any IAM principal with kms:Decrypt and DynamoDB read access can decrypt every row — that’s back to “trust me bro”. With browser AES, the key lives only in the URL fragment; the server never has the ingredient to decrypt anything it holds.
  • KMS payload limits. kms:Encrypt caps plaintext at 4 KB. Using browser AES with KMS as a pure “secretId pointer” sidesteps this entirely.
  • The server role shrinks to a dumb store. The Lambda receives an opaque blob, saves it, and returns it on demand. There’s no plaintext path through the server at all.

The Encryption Context

One small detail in the encrypt handler that’s worth not glossing over:

EncryptionContext: {
purpose: "secret-storage",
application: "secrets-exchanger",
}

EncryptionContext in KMS is additional authenticated data — it isn’t secret, but it must match exactly on decrypt or KMS refuses. Even though KMS now only wraps the secretId (not the encryption key), the context still matters: a token minted for this app can’t be replayed against a different workload that shares the same KMS key, and it shows up in CloudTrail so I can see why a decrypt happened, not just that it did.

Backend Notes

The two Lambda handlers (store-encrypted-secret.handler.ts and read-secret.handler.ts) are simpler than they were before moving to browser-side encryption. The non-obvious choices:

  • The store handler is a dumb write. It receives { encryptedData }, generates a UUID, stores the opaque blob, and KMS-wraps only { secretId } as a retrieval pointer. It never touches a key, a nonce, or plaintext.
  • The retrieve handler burns on read, unconditionally. It unwraps the KMS token to get the secretId, fetches the row, deletes it immediately, then returns the ciphertext. The secret is gone even if the browser later fails to decrypt or the passphrase is wrong — there’s no recovery path. This is intentional: the only way to prevent replay once the ciphertext is returned is to delete before responding.
  • Passphrase check moved to the browser. The server never sees the passphrase. After client-side AES-GCM decryption, the browser parses the plaintext JSON and verifies the passphrase locally. A mismatch shows an explicit permanent-error message.
  • Narrow catch. Only InvalidCiphertextException from KMS is mapped to a clean 400 — anything else is rethrown so it shows up in CloudWatch with a real stack trace. Swallowing errors in security code is how you end up debugging “it just doesn’t work” tickets in production.
  • 24-hour ceiling via DynamoDB TTL. If nobody ever opens the link, DynamoDB sweeps the row on its own schedule.

Frontend Notes

The Angular app now has a ClientEncryptionService that does the real work. The full web app source is a five-minute read; the highlights:

ClientEncryptionService uses the browser’s native crypto.subtle Web Crypto API — no third-party library. It generates a fresh AES-256-GCM key per secret, picks a random 96-bit nonce, and returns a single blob: base64(nonce || ciphertext || authTag). The nonce is prepended so the decrypt side can split it off deterministically.

The AES key travels only in the URL fragment. After the store call returns the KMS token, the create component builds the shareable URL:

`?encryptedInput=${encodeURIComponent(kmsToken)}#key=${encodeURIComponent(keyB64)}`

The query string (?encryptedInput=) goes to the server on the decrypt call. The fragment (#key=) never does — HTTP clients strip it before sending the request. The key is invisible in server logs, CloudFront access logs, and anywhere else traffic is recorded server-side.

The read component parses the fragment on ngOnInit. It reads window.location.hash, extracts the key, and sets a keyMissing signal if it’s absent — so a link shared without the fragment (e.g. pasted without the # part) shows a clear “incomplete link” state before the user even submits.

Burn-before-return means a wrong passphrase is permanent. The secret is deleted server-side before the ciphertext is returned to the browser. If the passphrase check fails after client-side decryption, the error is explicit: “Wrong passphrase. The secret has been destroyed and cannot be recovered.” That’s the honest trade-off of moving passphrase verification off the server.

Auto-copy to clipboard on encrypt success and a passphrase visibility toggle round out the UX — the same reasoning as before: single-use flows punish footguns hard.

Cost & Abuse Protection

A few cheap layers in the API construct and Lambda construct keep a runaway bot from turning into a five-figure KMS bill:

  • API Gateway throttle: 10 req/sec sustained, 20 burst. Far above any human use, far below “interesting”.
  • JSON Schema request validation at the gateway: 4 KB cap on secretString, 256 bytes on passphrase. Malformed bodies never hit the Lambda.
  • reservedConcurrentExecutions: 10 on each function. Even if a bot gets past the throttle, the Lambdas still won’t scale past 10 in flight.

None of these are exotic — they’re just defaults you have to actively choose to set, because CDK won’t nag you about cost ceilings the way cdk-nag will about IAM.

CDK and Projen: Standard Stuff

The CDK layer is the boring half of this project — and that’s the highest possible compliment for IaC. The whole thing is generated by Projen from a small .projenrc.ts, and the stacks split along the usual lines:

  • StatefulStack — DynamoDB table + KMS key. Things that should outlive a redeploy.
  • apiStack — Lambdas and API Gateway. Throwaway compute.
  • websiteStack — S3 + CloudFront for the SPA.
  • certificateStack / githubOidcStack — ACM cert in us-east-1 and the OIDC role for GitHub Actions deployments.

If you’ve built a CDK app before, none of this will surprise you. The interesting parts of the system live in the two Lambda handlers we already covered.

What I’d Do Differently

A short list, in the spirit of not pretending the design is finished. (Browser-side AES-256-GCM encryption and the URL fragment key pattern have since been implemented — see the repo for the current state.)

  • Add a WAF. Throttling and reserved concurrency are good cost controls but not bot mitigation. A small WAF rule set would push that work off the API Gateway.
  • Rotate the KMS key. Currently enableKeyRotation: false (with a cdk-nag suppression admitting the trade-off). One-line fix, worth doing once anything real lives in the system.
  • Recover gracefully from wrong passphrases. With browser-side encryption, the server burns the secret before the browser can verify the passphrase — a wrong guess is permanent. One way around this: move passphrase verification into the AES-GCM additional authenticated data (AAD), so a wrong passphrase fails the auth-tag check before the server is even called.

Wrap-Up

The thing I like about this project isn’t that it’s clever — the design is standard envelope encryption with a small twist about where the key is allowed to travel. It’s that the resulting property is easy to explain in one sentence to a non-cryptographer:

Your secret is split in three. The ciphertext is in my database. The lookup token is in the URL’s query string. The decryption key is in the URL’s fragment — the part browsers never send to a server. I hold one piece, and it’s the useless one.

Full code in the repo, live version at secrets-exchanger.andi-john-dev.de. Send something to yourself and watch the link burn.