1. Always verify server-side after redirect
When a payment gateway redirects back to your callback URL,
the URL parameters (like status=Completed)
can be typed by anyone. They prove nothing.
Always verify by calling the gateway API server-side.
Khalti
// โ
CORRECT โ always call lookupPayment() server-side
@GetMapping("/khalti/callback")
public ResponseEntity<String> callback(@RequestParam String pidx) {
KhaltiLookupResponse lookup = khaltiClient.lookupPayment(pidx);
if (!lookup.isPaymentSuccessful()) {
return ResponseEntity.badRequest().body("Payment not confirmed");
}
// โ
Now safe to mark as paid
orderService.markAsPaid(pidx);
return ResponseEntity.ok("Success");
}
eSewa
// โ
CORRECT โ verifyCallback() does all three checks:
// 1. Decode Base64 callback data
// 2. Verify HMAC-SHA256 signature (tamper protection)
// 3. Call eSewa status API for final confirmation
@GetMapping("/esewa/callback")
public ResponseEntity<String> callback(@RequestParam String data) {
EsewaClient.EsewaVerificationResult result =
esewaClient.verifyCallback(data);
if (!result.isPaymentSuccessful()) {
return ResponseEntity.badRequest().body("Payment not confirmed");
}
// โ
Now safe to mark as paid
orderService.markAsPaid(result.callbackData().transactionUuid());
return ResponseEntity.ok("Success");
}
ConnectIPS
// โ
CORRECT โ always call validateTransaction() server-side
@GetMapping("/connectips/callback")
public ResponseEntity<String> callback(
@RequestParam String txnId,
@RequestParam String referenceId,
@RequestParam long txnAmt) {
ConnectIpsValidateResponse response =
connectIpsClient.validateTransaction(txnId, referenceId, txnAmt);
if (!response.isPaymentSuccessful()) {
return ResponseEntity.badRequest().body("Payment not confirmed");
}
// โ
Now safe to mark as paid
orderService.markAsPaid(referenceId);
return ResponseEntity.ok("Success");
}
2. Always store identifiers before redirecting
Save the payment identifier to your database before redirecting the user to the payment page. If the user's browser crashes or they close the tab, you still have the identifier to verify later.
// โ
Khalti โ save pidx before redirecting
KhaltiInitiateResponse response = khaltiClient.initiatePayment(request);
orderRepo.savePidx(orderId, response.pidx()); // โ save FIRST
return response.paymentUrl(); // โ then redirect
// โ
eSewa โ save uuid before returning payload
String uuid = EsewaClient.generateTransactionUuid();
orderRepo.saveUuid(orderId, uuid); // โ save FIRST
EsewaFormPayload payload = esewaClient.buildFormPayload(amount, uuid);
return payload; // โ then return to frontend
// โ
ConnectIPS โ save txnId before returning payload
String txnId = "TXN-" + orderId;
orderRepo.saveTxnId(orderId, txnId); // โ save FIRST
ConnectIpsFormPayload payload = connectIpsClient.buildFormPayload(request);
return payload;
3. Verify the amount was not tampered
A malicious user could potentially manipulate callback data to make it appear they paid a different amount. Always verify the amount matches what you originally charged.
// โ
Khalti โ verify amount after lookup
KhaltiLookupResponse lookup = khaltiClient.lookupPayment(pidx);
if (!lookup.isAmountValid(expectedAmountPaisa)) {
// โ ๏ธ Amount mismatch โ potential fraud attempt!
log.error("Amount mismatch | expected={} | received={}",
expectedAmountPaisa, lookup.totalAmount());
return ResponseEntity.badRequest().body("Amount mismatch");
}
// โ
eSewa โ verifyCallback() calls status API which returns the amount
EsewaClient.EsewaVerificationResult result = esewaClient.verifyCallback(data);
// result.statusResponse().totalAmount() โ compare to your order amount
4. Use environment variables for all secrets
Secret keys hardcoded in source code or YAML files end up in your Git history โ even if you delete them later.
# โ
CORRECT โ values from environment variables
nepalpay:
khalti:
secret-key: ${KHALTI_SECRET_KEY}
esewa:
secret-key: ${ESEWA_SECRET_KEY}
connectips:
app-password: ${CONNECTIPS_APP_PASSWORD}
pfx-password: ${CONNECTIPS_PFX_PASSWORD}
# โ WRONG โ hardcoded secret in YAML
nepalpay:
khalti:
secret-key: live_secret_key_abc123xyz # exposed forever in git history!
5. Protect your ConnectIPS .pfx file
The CREDITOR.pfx file contains your RSA private key. Anyone with this file can generate valid ConnectIPS payment tokens.
# โ
Add to .gitignore โ NEVER commit the .pfx file
*.pfx
CREDITOR.pfx
# โ
Load via file path from environment variable
nepalpay:
connectips:
pfx-path: ${CONNECTIPS_PFX_PATH} # e.g. file:/app/CREDITOR.pfx
pfx-password: ${CONNECTIPS_PFX_PASSWORD}
On production servers: place the .pfx file outside the web-accessible directory and reference it by absolute path.
Never Do These Things
Khalti sends ?status=Completed&pidx=xxx in the redirect URL.
eSewa sends ?data=BASE64.
ConnectIPS sends ?txnId=xxx.
None of these are confirmation of payment by themselves.
Always verify using the client methods above.
Secret keys must exist only on your backend server. Never in Angular, React, Vue, or any client-side JavaScript. Anyone who opens DevTools can read frontend variables.
Even if you delete a hardcoded secret from a file, it remains in your Git history forever. Use environment variables for everything.
The ConnectIPS .pfx file contains your RSA private key.
Add *.pfx to your .gitignore immediately.
Always test the complete payment flow in sandbox mode before
switching to production. NepalPay defaults to
sandbox=true to prevent accidental
production charges during development.
How NepalPay Protects You
NepalPay enforces these security patterns by design โ not as optional helpers, but as the primary API surface.
| Threat | Gateway | How NepalPay Handles It |
|---|---|---|
| Redirect URL faking | All |
lookupPayment(),
verifyCallback(),
validateTransaction() enforce server-side verification
|
| eSewa response tampering | eSewa |
verifyCallback() re-computes HMAC-SHA256 signature
and throws EsewaException on mismatch
|
| Amount manipulation | Khalti |
isAmountValid(expectedPaisa) helper provided
on KhaltiLookupResponse
|
| Accidental production calls | All |
sandbox=true is the default for all gateways.
You must explicitly set sandbox=false for production.
|
| Secret key exposure in logs | All | Secret keys are never logged at any level inside NepalPay |
| ConnectIPS token forgery | ConnectIPS | RSA-SHA256 signing with .pfx private key happens server-side. Token is never exposed to the frontend. |
Pre-Launch Security Checklist
Before switching to sandbox=false and going live,
verify every item:
| Item | Khalti | eSewa | ConnectIPS |
|---|---|---|---|
| Server-side verification after callback | โ
lookupPayment(pidx) |
โ
verifyCallback(data) |
โ
validateTransaction() |
| Identifier stored before redirect | pidx saved to DB | uuid saved to DB | txnId saved to DB |
| Amount verified after payment | โ
isAmountValid() |
โ compare statusResponse | โ compare response |
| Secret keys in environment variables | โ All gateways | ||
| .pfx file not in Git | N/A | N/A | โ
*.pfx in .gitignore |
| sandbox=false set for production | โ All gateways | ||
| Production merchant codes configured | Live secret key | Real product code | Real merchant ID |