Back to Posts
Dec 25, 2025

HMAC signatures are a terrible way to secure payment APIs

Last month, I integrated eSewa into a client project.

For those outside Nepal, eSewa is like Venmo meets Paypal - a mobile wallet everyone uses for everything from bus tickets to college fees. Their API documentation proudly explains their security model - HMAC-SHA256  signatures to verify payment requests.

My first reaction was - why?

Not because HMAC is bad - it’s not. But because in 2025, we have better options everyone seems to ignore.

Let me show you why I think there’s a better approach hiding in plain sight.

How eSewa’s signature system works

When you initiate a payment, you send them a form with parameters like transaction ID, amount, and product code. And of course, it has to be encrypted so others cannot intercept and modify them - so you send a signature.

Here’s the actual signature generation:

import CryptoJS from 'crypto-js'; const signatureString = `total_amount=${amount},transaction_uuid=${uuid},product_code=${code}`; const signature = CryptoJS.HmacSHA256(signatureString, SECRET_KEY); const base64Signature = CryptoJS.enc.Base64.stringify(signature);

The signature is a hash of specific fields (only total_amount, transaction_uuid, and product_code in this case) combined with a secret key that only you and eSewa know. When eSewa receives your request, they regenerate the signature using their copy of your secret key.

If the signatures match, they know:

  1. The request came from you (auth)
  2. Data wasn’t tampered with (integrity)
  3. You can’t later deny making the request (non-repudiation)

It’s elegant. It’s simple. Same inputs always produce the same output. No state to manage, no session tokens to expire. Just pure cryptographic verification.

The hidden complexity

Here’s what’s interesting, eSewa’s documentation specifies:

esewa-payment-integration-parameter-ordering-matters

The parameters (total_amount,transaction_uuid,product_code) should be in the same order while creating the signature.

That same order requirement? It’s load-bearing. If you generate your signature as transaction_uuid,total_amount,product_code instead of total_amount,transaction_uuid,product_code, it fails silently.

HMAC itself is bulletproof - it has no known practical attacks. However, raw string concatenations for cryptographic operations probably isn’t the best approach for its implementation.

AWS SigV4 uses canonicalization  to sort parameters before signing them, so order won’t matter. They should probably handle this logic server-side.

What the alternatives look like

Option 1: JWTs (JSON Web Tokens)

Instead of custom signature schemes, you could use JWTs. They’re standardized (RFC 7519 ), have libraries in every language, and handle serialization complexity well:

const jwt = require('jsonwebtoken'); const payload = { total_amount: 100, transaction_uuid: '123-456', product_code: 'EPAYTEST' }; const token = jwt.sign(payload, SECRET_KEY, { algorithm: 'HS256', expiresIn: '15m' });

JWT (using JSON) has consistent serialization rules, they solve the ordering problem. And give you expiration dates for free.

Option 2: Public Key Cryptography

Even better: ditch symmetric keys. With RSA or ECDSA signatures, you sign requests with your private key, and eSewa verifies with your public key:

const crypto = require('crypto'); const sign = crypto.createSign('RSA-SHA256'); sign.update(JSON.stringify(payload)); const signature = sign.sign(privateKey, 'base64');

This is how TLS works. It’s how software update systems work. It’s provably more secure than shared secrets because the verification key can be public - if it leaks, nothing happens. Only your private key matters.

Why HMAC?

Performance? Not really. RSA signing is slower than HMAC, but we’re talking microseconds for a payment request you make once every few seconds at most. The bottleneck is network latency, not crypto.

Complexity? Partially. Managing public/private keypairs is harder than managing shared secrets. You need to worry about key rotation, certificate distribution, and revocation. HMAC just needs… a password.

Where HMAC actually shines

I don’t want to sound like HMAC is wrong - it’s not. For webhook verification, it’s actually perfect. When eSewa sends you a callback saying “hey, this payment succeeded,” you need to verify it came from eSewa. HMAC solves this beautifully:

const receivedSignature = request.headers['signature']; const computedSignature = crypto .createHmac('sha256', SECRET_KEY) .update(request.body) .digest('base64'); if (receivedSignature !== computedSignature) { throw new Error('Invalid webhook signature'); }

No public key infrastructure needed. No certificate management. Just hash and compare.

The problem is using the same pattern for outgoing payment requests. Those should use something with built-in expiration, better error messages, and standardized tooling.

What I’d do differently

If I were building a payment API today (and I’ve thought about this more than is probably healthy), here’s the stack:

  1. OAuth 2.0 for merchant authentication: Industry standard, tons of tooling, handles token refresh automatically
  2. Short-lived JWTs for payment requests: Include amount, merchant ID, and expiration in the token. Sign with HS256 for simplicity or RS256 for better security
  3. HMAC for webhooks: Because it’s actually the right tool for this job

This gives you:

The elephant in the room

None of this matters if you leak your secret key.

One article I found on Medium about eSewa integration stores the secret in NEXT_PUBLIC_ESEWA_SECRET_KEY. That NEXT_PUBLIC prefix? In Next.js, that means it’s exposed to the browser. Anyone can open DevTools and see it.

This is why the choice of signature scheme almost doesn’t matter. You can use the most sophisticated cryptographic protocol in existence, but if the key leaks, you’re cooked. The real security work isn’t in choosing HMAC vs JWT vs RSA. Just:

Keep your secret key in .env.local file. Do not use the NEXT_PUBLIC prefix.

# .env.local ESEWA_SECRET_KEY=8gBm/:&EnhH.1/q

You can handle encryption with a server-side signing function (server action).

// app/actions/payments.ts "use server"; // server-only code import crypto from 'crypto'; export async function generatePaymentSignature(amount: string, uuid: string, code: string) { const secret = process.env.ESEWA_SECRET_KEY; if (!secret) throw new Error("Missing Secret Key"); const signatureString = `total_amount=${amount},transaction_uuid=${uuid},product_code=${code}`; const signature = crypto .createHmac('sha256', secret) .update(signatureString) .digest('base64'); return signature; }

You can simply call this function in your frontend component. The browser never sees the secret, only receives the final signature.

// app/checkout/page.tsx "use client"; import { generatePaymentSignature } from '../actions/payments'; export default function CheckoutPage() { const handlePayment = async () => { const amount = "100"; const uuid = "unique-id-123"; const code = "EPAYTEST"; const signature = await generatePaymentSignature(amount, uuid, code); console.log("Secure Signature generated on server:", signature); // redirect to eSewa with signature... }; return <button onClick={handlePayment}>Pay with eSewa</button>; }

Closing thoughts

eSewa’s API works. Millions of transactions flow through it daily. The HMAC signature scheme, despite its quirks, provides real security value.

I believe modern tools are better - JWTs are mature, key management is easier, and we understand the failure modes better. Yet we keep building new payment systems that look identical to the old ones.

Maybe that’s fine. Maybe moving signature generation server-side, adding request expiration, and setting up secrets rotation would be finer. I’m not sure if finer is a real word.

The next time you integrate a payment API, take a minute to ask what those lines of code do. Ask whether the security model makes sense. And keep your secret keys out of NEXT_PUBLIC_* environment variables.


Footnotes

  1. eSewa uses https://rc-epay.esewa.com.np for testing and https://epay.esewa.com.np for production.

  2. If you’re curious about the math behind HMAC, the original RFC 2104  is surprisingly readable. The core insight is using a hash function twice with different key derivations to prevent length extension attacks.

Related Posts