NepalPay enforces secure patterns by design โ server-side verification, HMAC signature validation, RSA signing, and constant-time comparison are all built in and tested. But you must also follow the rules below.
1. Always Verify Server-Side After Redirect
When a gateway redirects to your callback URL, the URL parameters can be typed by anyone. They prove nothing. Always verify using the gateway API from your backend.
Khalti
@GetMapping("/khalti/callback")
public ResponseEntity<String> callback(
@RequestParam String pidx,
@RequestParam(required = false) String status) {
// โ ๏ธ IGNORE ?status=Completed from URL โ it can be forged
// โ
ALWAYS verify server-side
KhaltiLookupResponse lookup = khaltiClient.lookupPayment(pidx);
if (!lookup.isPaymentSuccessful()) {
return ResponseEntity.badRequest()
.body("Not confirmed: " + lookup.status());
}
// Verify amount from YOUR database
String orderId = orderRepo.findOrderByPidx(pidx);
long expectedPaisa = orderRepo.getAmountPaisa(orderId);
if (!lookup.isAmountValid(expectedPaisa)) {
log.error("SECURITY: Amount mismatch | pidx={}", pidx);
return ResponseEntity.badRequest().body("Amount mismatch");
}
orderRepo.markPaid(orderId, lookup.transactionId());
return ResponseEntity.ok("Confirmed");
}
eSewa
@GetMapping("/esewa/callback")
public ResponseEntity<String> callback(@RequestParam String data) {
// verifyCallback: decode Base64 โ verify HMAC โ call status API
EsewaClient.EsewaVerificationResult result =
esewaClient.verifyCallback(data);
if (!result.isPaymentSuccessful()) {
return ResponseEntity.badRequest().body("Not confirmed");
}
orderRepo.markPaid(result.callbackData().transactionUuid());
return ResponseEntity.ok("Confirmed");
}
ConnectIPS
@GetMapping("/connectips/callback")
public ResponseEntity<String> callback(
@RequestParam String txnId,
@RequestParam String referenceId,
@RequestParam long txnAmt) {
// validateTransaction uses HTTP Basic Auth with NCHL
ConnectIpsValidateResponse res =
connectIpsClient.validateTransaction(
txnId, referenceId, txnAmt);
if (!res.isPaymentSuccessful()) {
return ResponseEntity.badRequest().body("Not confirmed");
}
orderRepo.markPaid(referenceId);
return ResponseEntity.ok("Confirmed");
}
Fonepay
// verifyCallback re-computes HMAC-SHA512, compares DV,
// THEN checks PS=success โ never trust PS alone
// Build callback from query parameters
// Fonepay sends all params as GET query params to your returnUrl
FonepayCallbackResponse callback =
FonepayCallbackResponse.of(
PRN, // your original Product Reference Number
PID, // Fonepay merchant code
PS, // payment status โ DO NOT trust alone
RC, // response code
UID, // Fonepay unique transaction ID
BC, // bank code
INI, // transaction initiator
pAmt, // paid amount
rAmt, // refund amount
dv // HMAC-SHA512 signature โ the only real proof
);
// verifyCallback: re-computes HMAC-SHA512, compares DV (constant-time),
// THEN checks PS=success โ only after signature verification passes
FonepayClient.FonepayVerificationResult result =
fonepayClient.verifyCallback(callback);
if (result.isPaymentSuccessful()) {
// Load order using saved PRN
orderRepo.markPaid(callback.prn());
}
2. Store Identifiers Before Redirecting
Save payment identifiers to your database before redirecting the user. If the browser crashes or the user closes the tab, you still have the ID to verify later.
// โ
Khalti
KhaltiInitiateResponse res = khaltiClient.initiatePayment(request);
orderRepo.savePidx(orderId, res.pidx()); // โ SAVE FIRST
return res.paymentUrl(); // โ THEN redirect
// โ
eSewa
String uuid = EsewaClient.generateTransactionUuid();
orderRepo.saveUuid(orderId, uuid); // โ SAVE FIRST
EsewaFormPayload payload = esewaClient.buildFormPayload(amount, uuid);
return payload; // โ THEN return to frontend
// โ
ConnectIPS
String txnId = "TXN-" + orderId;
orderRepo.saveTxnId(orderId, txnId); // โ SAVE FIRST
ConnectIpsFormPayload payload =
connectIpsClient.buildFormPayload(req);
return payload;
// โ
Fonepay
String prn = "FP-" + orderId;
orderRepo.savePrn(orderId, prn); // โ SAVE FIRST
String redirectUrl = fonepayClient
.buildRedirectParams(req).redirectUrl();
return redirectUrl;
3. Verify the Amount Was Not Tampered
A malicious user could initiate a payment for NPR 1 and try to claim a product worth NPR 1000. Always compare the gateway-confirmed amount with the amount stored in YOUR database.
// Khalti โ isAmountValid() checks totalAmount matches
KhaltiLookupResponse lookup = khaltiClient.lookupPayment(pidx);
// Load expected amount from YOUR database โ NOT from redirect URL
long expectedPaisa = orderRepo.getAmountPaisa(orderId);
if (!lookup.isAmountValid(expectedPaisa)) {
log.error(
"SECURITY ALERT: Amount mismatch | orderId={}" +
" | expected={}paisa | received={}paisa",
orderId, expectedPaisa, lookup.totalAmount()
);
// Do NOT fulfil the order โ investigate immediately
return ResponseEntity.badRequest().body("Amount mismatch");
}
if (!lookup.isPaymentSuccessful()) {
return ResponseEntity.badRequest().body("Not paid");
}
// Both checks passed โ safe to fulfil
orderRepo.markPaid(orderId, lookup.transactionId());
4. Use Environment Variables for All Secrets
# โ
Correct โ values from environment variables
nepalpay:
khalti:
secret-key: ${KHALTI_SECRET_KEY}
# โ Wrong โ hardcoded in YAML
nepalpay:
khalti:
secret-key: live_secret_key_abc123
# This is now in your Git history forever
# Even after you delete it, it remains in past commits
If you accidentally commit a real secret key, immediately rotate it on the gateway dashboard AND clean your Git history. Deleting the line in a new commit is not enough โ the key remains in the commit history.
5. Protect Your ConnectIPS .pfx File
The CREDITOR.pfx contains your RSA private
key. Anyone with it can forge ConnectIPS payment tokens
and steal from your merchant account.
# .gitignore โ add these BEFORE your first commit
*.pfx
CREDITOR.pfx
# Verify it is not already tracked
git check-ignore -v CREDITOR.pfx
# application.yml โ always load from environment variable path
nepalpay:
connectips:
pfx-path: ${CONNECTIPS_PFX_PATH} # file:/app/CREDITOR.pfx
pfx-password: ${CONNECTIPS_PFX_PASSWORD}
NepalPay loads the .pfx using
Spring's ResourceLoader with
try-with-resources โ no file
descriptor leaks on application restarts.
The RSA private key is cached at startup โ
no KeyStore loading on every payment.
Never Do These Things
Never trust redirect parameters alone.
?status=Completed,
?PS=success,
?data=BASE64
can all be faked. Always verify server-side.
Never put secret keys in frontend code. Anyone who opens DevTools can read frontend variables. Keys belong only on your backend server.
Never hardcode secrets in source code. Even if you delete them, they remain in your Git history. Use environment variables for all real credentials.
Never commit CREDITOR.pfx to Git.
Add *.pfx to .gitignore
immediately โ before your very first commit.
Never set sandbox=false
without end-to-end testing.
NepalPay defaults all gateways to
sandbox=true to prevent accidental
production charges during development.
Never call
callback.paymentStatus()
directly on FonepayCallbackResponse
โ there is no
isPaymentSuccessful() on it by design.
The only safe path is
fonepayClient.verifyCallback(callback)
which verifies the HMAC-SHA512 DV signature first.
How NepalPay Protects You
| Threat | Gateway | NepalPay Protection |
|---|---|---|
| Redirect URL faking | All | Server-side verification methods built into every client |
| eSewa response tampering | eSewa | verifyCallback() re-computes HMAC-SHA256 โ throws on mismatch |
| eSewa HMAC timing attack | eSewa | MessageDigest.isEqual() โ constant-time comparison |
| Fonepay callback faking | Fonepay | verifyCallback() re-computes HMAC-SHA512 โ throws on mismatch |
| Fonepay HMAC timing attack | Fonepay | MessageDigest.isEqual() โ constant-time comparison |
| Fonepay PS bypass | Fonepay | isPaymentSuccessful() removed from raw callback record in v1.1.1 |
| Amount manipulation | Khalti | isAmountValid(expectedPaisa) helper on lookup response |
| Accidental production charges | All | sandbox=true is the default โ must explicitly opt into production |
| ConnectIPS token forgery | ConnectIPS | RSA signing server-side โ TOKEN value only reaches frontend, private key never does |
| PFX file descriptor leak | ConnectIPS | try-with-resources on getInputStream() โ fixed in v1.1.1 |
| Wrong PFX alias selected | ConnectIPS | isKeyEntry() check on all aliases โ safe for multi-entry PFX |
| Khalti refund false-negative | Khalti | isRefundSuccessful() uses refunded boolean only โ fixed in v1.1.1 |
Pre-Launch Security Checklist
Before setting sandbox=false for any
gateway, verify every item:
| Item | Khalti | eSewa | ConnectIPS | Fonepay |
|---|---|---|---|---|
| Server-side verification on callback | โ
lookupPayment() |
โ
verifyCallback() |
โ
validateTransaction() |
โ
verifyCallback() |
| Identifier saved before redirect | pidx saved | uuid saved | txnId saved | prn saved |
| Amount verified after payment | โ
isAmountValid() |
โ status COMPLETE | โ status SUCCESS | โ compare P_AMT |
| Secrets in env vars only | โ All gateways | |||
| .pfx not in Git | N/A | N/A | โ
*.pfx in .gitignore |
N/A |
| End-to-end tested in sandbox | โ All gateways | |||
sandbox=false set |
โ All gateways before go-live | |||