โ„น๏ธ NepalPay enforces secure patterns by design โ€” server-side verification, HMAC signature validation, and RSA signing are all built in. But you must also follow the rules on this page.

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

โŒ Never trust redirect parameters alone

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.

โŒ Never put secret keys in frontend code

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.

โŒ Never hardcode secrets in source code

Even if you delete a hardcoded secret from a file, it remains in your Git history forever. Use environment variables for everything.

โŒ Never commit your CREDITOR.pfx to Git

The ConnectIPS .pfx file contains your RSA private key. Add *.pfx to your .gitignore immediately.

โŒ Never set sandbox=false without testing

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