Why this exists
A buyer pays a seller by UPI and holds up a phone. On the screen is a payment screenshot: an amount, a green tick, a 12-digit transaction ID. The seller has a few seconds to decide whether to hand over the goods, and the buyer is waiting.
That screenshot is the wrong proof. It is a picture of a payment, made on the buyer's own phone, and anyone changes the amount in a photo editor in a minute. Razorpay describes fake payment screenshots as a scam aimed at exactly this moment.
The harder case is a screenshot that is completely real. Last week's genuine payment, shown again today for a second order, carries a real transaction ID that matches a real credit every time anyone looks it up. The standard advice, open your bank app and match the UTR, catches the edited screenshot if you have time. It never catches the real one shown twice.
The people who lose money are the ones with no payment soundbox and no merchant dashboard: home tutors, resellers, someone selling a phone online, small stalls paid to a personal UPI ID. They have only the buyer's phone to go on. The scale is large. UPI handled a record 24.51 billion transactions in August 2026, according to NPCI data reported by Business Standard.
Some guides tell sellers to check the date digits inside the UTR. That advice is stale. NPCI revised the reference number in 2024 and told banks to stop validating those digits by 31 May 2024.
Meanwhile the seller's bank has already sent the record that settles the question: the credit alert email. Banks sign those emails with DKIM, checked against a key the bank publishes in its own DNS. Change one digit and the signature fails. Yet nobody treats that email as the source of truth. People trust the screenshot, the one thing in the exchange the buyer controls.
So Unforged has two rules. Check the payment against the bank's signed email, in code. Then let each signed record pay for one order only, so a real screenshot shown twice is caught the second time. Exactly once here means one approval per record, even when two claims arrive at the same instant.
I built it now, with AWS, because the parts fit a solo build in two days. Aurora DSQL is a serverless SQL database where one unique key inside one transaction is the whole claim-once rule, with no VPC and no stored password. AWS KMS signs a receipt the buyer checks without trusting me. Amazon Textract reads the screenshot. Claude Code, connected to my AWS account through the AWS MCP Server, built and deployed it, and CloudTrail recorded every call it made.
The result is Unforged, built for the AWS Builder Center Zero to Shipped hackathon. This is how I built it, what broke, and what I measured.
How the signature check works, in 5 steps
The live page uses a real signed email from a public mailing list in place of a bank alert. The check is the same.
- The sender's server signs the email. It adds a
DKIM-Signatureheader. On the sample,d=gnu.orgnames the signing domain,s=fencepost-gnu-orgnames the key,h=lists the signed headers,bh=holds the SHA-256 hash of the body, andb=holds the signature over all of it. - Unforged fetches the public key from DNS at
fencepost-gnu-org._domainkey.gnu.org. For a bank this is the bank's own DNS, and nobody else holds the private key. - It recomputes the body hash. On the live page your browser does this too, and the result matches
bh=exactly. Change one letter and 43 of the 44 characters of the hash change. - It checks
b=against the key. Rewritingbh=to match an edited body does not help, becauseb=coversbh=. - It checks who signed. The signing domain must match the From address, and for a bank alert the From domain must be on the allowlist of bank domains.
The rule I set first
Before any code, I wrote down one rule. A model reads, and code decides.
The reader copies text off the screenshot into fixed fields. It never sees the bank alert and never returns a verdict. Four if statements and one unique key decide instead, and each answer carries its reason in plain words: Unreadable, Not found yet, Amount mismatch, Payee mismatch, Already claimed, Verified. There is no fraud score, because nobody acts on "73% likely genuine". I also wrote down what I would not build: no login, no WhatsApp bot, no multi-region database, and no parser for a bank I had no real sample from.
Claim once, under real concurrency
Every approved check inserts a claim row for the bank credit it matched, inside one transaction in Aurora DSQL. The claim's key is unique, so only one insert per credit ever succeeds. A second order with the same real screenshot hits the key and gets Already claimed, with the earlier order and its time.
DynamoDB does this with a conditional write too. I picked DSQL because the app reads its data relationally and the guarantee becomes a unique key any Postgres reader can audit. The cost is optimistic concurrency. A DSQL transaction loses at commit when another wrote first, so the claim path retries conflicts, and a retry that meets the unique key becomes Already claimed instead of a second approval.
To prove it, I built a race. Each round seeds one fresh credit and fires 50 claims at it at once from one Lambda, then does the same against a control table that checks before inserting. I ran 20 rounds on the live stack with pnpm measure:race. The guarded path produced exactly one winner every round. The control approved all 50 every round, 1,000 approvals for 20 credits.
The agent's first race approved one credit 50 times
The agent worked as its own IAM user, unforged-agent, so CloudTrail attributes every call. That came to 1,198 management events between the evening of 1 Oct and the afternoon of 2 Oct, IST, nine of them logged by the AWS MCP Server itself.
The first time it ran the race on the live stack, the guarded path approved 50 of 50 claims for one credit. The whole point of the project, broken on its first real test.
It left the claim code alone and queried the cluster's sys.jobs table. The index build had failed with found duplicate key(s) while validating index uniqueness. DSQL builds ASYNC indexes in the background, and they enforce nothing until the build completes, as the AWS docs say. The race ran seconds after the migration and wrote duplicates while the index was building. The code was right and the deploy order was wrong. The migration now waits on sys.wait_for_job for every async index and fails the deploy if a build fails. The next three races each returned one winner and 49 Already claimed.
It happened again a day later. A path that claims any signed email once started on the same kind of async index, and a review agent I ran against the live site claimed one email twice, half a second apart, while it built. Both came back Verified. The same review found a second hole. The first claim key came from one signature's b= value, so deleting one of an email's two signatures made a new key and a second approval. The fix made the claim key the table's primary key, which DSQL enforces from the moment the table exists, built only from parts every signature covers. Every stripped copy has come back Already claimed since. Later, 10 rounds of 50 simultaneous claims on one email, each through CloudFront with its own DKIM check, gave exactly one Verified and 49 Already claimed every round, with 0 of 500 requests failing, run with pnpm measure:record-race.
Bedrock said no, so the reader changed
On a fresh account, every Bedrock vision model I tried returned Too many tokens per day. The daily quota was 0, and Service Quotas marked it not adjustable.
This is where "code decides" paid off. The reader was a replaceable part, so Amazon Textract went in as the last reader the same day. Textract returns text lines with positions. Code picks the transaction ID beside its label, the one rupee amount, and the UPI ID under "To". If a field has two candidates, it stays empty instead of being guessed. A missing UTR or amount makes the verdict Unreadable.
I measured Textract on two screenshots I made for the demo, one at ₹500 and an edited copy at ₹5,000, 10 reads each. It read the UTR, the amount and the payee right every time, at a p50 of 719 and 855 ms. One misread showed up on the way. Textract read the payee once as okicicl, so the parser saw two UPI IDs and left the field empty rather than pick one. Clean rendered images are the easy case. Real phone screenshots are the next measurement.
Measuring signatures I did not write
A test set the builder wrote proves little. So I measured the check on signed email anyone downloads. The set is the first 80 messages with a DKIM header in a public mailing-list archive, run with pnpm measure:signature.
43 of the 80 could not be checked, because the list had changed them in transit. The other 37 verified as archived, across 6 signing domains. Changing the first letter or digit of each body broke the signature 37 of 37 times. Rewriting the From header to alerts@hdfcbank.net got all 37 rejected, and none of the 80 was stored as a bank credit. None of these senders is a bank, so this tests the signature check and not the bank parser.
A receipt the buyer can check without trusting me
Every Verified claim writes a receipt in the same transaction as the claim: who signed the email, what it was claimed for, when, and the claim key. An AWS KMS key signs it with ECDSA on P-256. The private key never leaves KMS, and the public key is published, so anyone checks a receipt offline. Each receipt also carries the hash of the one before it, so removing a receipt breaks the chain.
I claimed 32 different signed emails into one ledger, 8 at a time. The receipts came out numbered 1 to 32 with no gaps or duplicates, after 29 conflict retries, and an offline audit found 0 breaks. A receipt with one field edited failed both its signature and its hash. The commands are pnpm measure:receipt-race, pnpm measure:chain and pnpm measure:receipt.
The buyer does not need a terminal. The receipt page carries a QR code the seller shows at the counter, and its Verify in your browser button checks the KMS signature and the hash with WebCrypto. The Ledger page does the same for a whole chain.
What stops each attack
| Attack | What stops it | Checked |
|---|---|---|
| Amount edited on a real screenshot | Compared with the bank's signed amount for that UTR | Unit test |
| Real screenshot shown for a second order | The credit's claim key is already taken | Live, 10/10 second claims refused |
| Two claims at the same instant | Unique key inside one transaction | Live, 20/20 rounds with one winner |
| One character changed in a signed email | Body hash no longer matches bh= |
Live, 37/37 |
| A bank address pasted into From | From is a signed header | Live, 37/37 |
| One of two signatures stripped | The claim key uses only content every signature covers | Live |
| A receipt edited | The KMS signature and the hash both fail | Live |
A signature over only part of the body (l=) |
Refused before any claim | Code check |
Try it yourself
The home page runs the proof on the live stack as it loads and lists every verdict with where to see it. Five run live on the public site. Verified, Already claimed and Rejected appear on Proof. On Screenshots, Run the real check opens a throwaway demo shop with no bank alert, and four sample screenshots come back Not found yet three times and Unreadable for the cropped one. Amount mismatch and Payee mismatch need a real signed bank alert in your shop, which reads each verdict aloud in Hindi or English. Status checks the DKIM key, Aurora DSQL and the KMS key live.
What I would tell myself at the start
- Put the decision in code from the first commit. It made the reader swappable the day the model became unavailable.
- Read your database's job table. An async index that has not finished is a constraint that does not exist yet.
- Give the agent its own identity. CloudTrail answered "what did the agent touch" for the rest of the build.
- Measure on data you did not write.
- Run a reviewer against your own live site. It found two holes I would have shipped.
What it does not do yet
Forwarding an email in Gmail rewrites it and breaks the signature, so sellers paste the original or upload the .eml. A blurry real screenshot comes back Unreadable by design. Nothing yet ties an alert to the shop that receives it. Banks that do not sign their alerts are out of reach. The bank path is built and unit-tested and has not run on a real bank alert, so I give no bank-side number.
The claim-once pattern comes from my earlier project, Stub. The code here is new.
Where it goes
The mechanism is general. Verify a signed email, then let its record be claimed once. Refunds, payslips, reimbursements and booking deposits all arrive as signed email today. Next is SES inbound, so sellers forward alerts instead of pasting them.
The project page, with the demo and every measurement, is on Builder Center. The code is at github.com/ashutosh887/unforged and the app is live at d1ajauwkb76on3.cloudfront.net.

