โ„น๏ธ

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