Building a Secure H5-to-Wallet Payment System: A Minimal Implementation Guide
When building H5 applications that need to integrate with third-party wallets for payments, many developers initially consider using plaintext deeplinks containing payment amounts and addresses. However, this approach introduces significant security risks. This tutorial will guide you through implementing a more secure, session-based approach that eliminates most vulnerabilities while remaining simple to implement.
Prerequisites
Before starting this tutorial, you should have:
- Basic understanding of Web3 payment flows
- Familiarity with REST API development
- Knowledge of mobile app deeplink mechanisms
- Understanding of basic cryptographic concepts (signatures, nonces)
Why Avoid Plaintext Deeplinks?
Using plaintext deeplinks like wallet://pay?amount=10&to=0x123... creates several security risks:
- URL hijacking: Malicious apps can intercept and modify payment parameters
- Man-in-the-middle attacks: Payment data can be tampered with during transmission
- Phishing attacks: Fake wallets can be launched with legitimate-looking URLs
- Parameter tampering: Users might not notice if amounts or addresses are modified
The Solution: Session-Based Payment Flow
Instead of embedding sensitive payment data in deeplinks, we'll implement a system where:
- H5 app creates a payment session and gets a session ID
- Only the session ID is passed to the wallet via deeplink
- Wallet fetches actual payment data from your backend
- Payment confirmation goes through your backend
Step-by-Step Implementation
Step 1: Create Payment Session Endpoint
First, implement an endpoint for creating payment sessions:
// Backend API - Create Payment Session
app.post('/api/payments/create', async (req, res) => {
const { amount, token, chainId, to } = req.body;
// Generate unique session ID
const sessionId = generateSecureId();
// Store payment data with expiration
await paymentSessions.set(sessionId, {
amount,
token,
chainId,
to,
status: 'pending',
createdAt: Date.now(),
expiresAt: Date.now() + 120000 // 2 minutes
});
res.json({
sessionId,
expiresAt: Date.now() + 120000
});
});
function generateSecureId() {
return crypto.randomBytes(16).toString('hex');
}Step 2: H5 Frontend Implementation
In your H5 app, create the payment session and launch the wallet:
// H5 Frontend - Initiate Payment
class PaymentManager {
async initiatePayment(paymentData) {
try {
// Create payment session
const response = await fetch('/api/payments/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(paymentData)
});
const { sessionId } = await response.json();
// Launch wallet with session ID only
const deeplink = `wallet://pay?sessionId=${sessionId}`;
window.location.href = deeplink;
return sessionId;
} catch (error) {
console.error('Payment initiation failed:', error);
throw error;
}
}
}
// Usage
const paymentManager = new PaymentManager();
paymentManager.initiatePayment({
amount: '10',
token: 'USDC',
chainId: 1,
to: '0xYourReceiveAddress'
});Step 3: Payment Info Retrieval Endpoint
Create an endpoint for wallets to fetch payment information:
// Backend API - Get Payment Info
app.get('/api/payments/info', async (req, res) => {
const { sessionId } = req.query;
if (!sessionId) {
return res.status(400).json({ error: 'Session ID required' });
}
const paymentData = await paymentSessions.get(sessionId);
if (!paymentData) {
return res.status(404).json({ error: 'Session not found' });
}
// Check expiration
if (Date.now() > paymentData.expiresAt) {
await paymentSessions.delete(sessionId);
return res.status(410).json({ error: 'Session expired' });
}
// Add nonce for replay protection
const nonce = crypto.randomBytes(8).toString('hex');
const responseData = {
amount: paymentData.amount,
token: paymentData.token,
chainId: paymentData.chainId,
to: paymentData.to,
nonce,
timestamp: Date.now()
};
// Optional: Add signature for data integrity
const signature = signPaymentData(responseData);
res.json({
...responseData,
signature
});
});Step 4: Wallet Integration (Pseudocode)
Here's how a wallet app would integrate with this system:
// Wallet App - Handle Payment Request
class WalletPaymentHandler {
async handlePaymentRequest(sessionId) {
try {
// Fetch payment info from backend
const response = await fetch(
`https://yourapi.com/api/payments/info?sessionId=${sessionId}`,
{
headers: {
'X-Wallet-Client': 'YourWalletApp'
}
}
);
const paymentInfo = await response.json();
// Verify signature (optional but recommended)
if (!this.verifySignature(paymentInfo)) {
throw new Error('Invalid payment data signature');
}
// Display payment confirmation to user
const userConfirmed = await this.showPaymentConfirmation(paymentInfo);
if (userConfirmed) {
// Execute transaction
const txHash = await this.executePayment(paymentInfo);
// Confirm payment with backend
await this.confirmPayment(sessionId, txHash);
}
} catch (error) {
console.error('Payment failed:', error);
throw error;
}
}
async confirmPayment(sessionId, txHash) {
await fetch('https://yourapi.com/api/payments/confirm', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Wallet-Client': 'YourWalletApp'
},
body: JSON.stringify({
sessionId,
txHash,
timestamp: Date.now()
})
});
}
}Step 5: Payment Confirmation Endpoint
Implement the payment confirmation endpoint:
// Backend API - Confirm Payment
app.post('/api/payments/confirm', async (req, res) => {
const { sessionId, txHash } = req.body;
const paymentData = await paymentSessions.get(sessionId);
if (!paymentData) {
return res.status(404).json({ error: 'Session not found' });
}
if (paymentData.status !== 'pending') {
return res.status(400).json({ error: 'Payment already processed' });
}
// Update session status
await paymentSessions.set(sessionId, {
...paymentData,
status: 'confirmed',
txHash,
confirmedAt: Date.now()
});
// Optional: Verify transaction on blockchain
// const isValid = await verifyTransaction(txHash, paymentData);
res.json({
status: 'confirmed',
txHash
});
});Step 6: Enhanced Security Features
Add these security enhancements to your implementation:
// EIP-712 Signature for Payment Data
function signPaymentData(paymentData) {
const domain = {
name: 'YourPaymentService',
version: '1',
chainId: paymentData.chainId
};
const types = {
Payment: [
{ name: 'amount', type: 'string' },
{ name: 'token', type: 'string' },
{ name: 'to', type: 'address' },
{ name: 'nonce', type: 'string' },
{ name: 'timestamp', type: 'uint256' }
]
};
return ethers.utils._TypedDataEncoder.hash(domain, types, paymentData);
}
// Session cleanup utility
function cleanupExpiredSessions() {
setInterval(async () => {
const sessions = await paymentSessions.getAll();
for (const [sessionId, data] of sessions) {
if (Date.now() > data.expiresAt) {
await paymentSessions.delete(sessionId);
}
}
}, 60000); // Run every minute
}Security Benefits
This implementation addresses the major security concerns:
| Security Risk | Solution |
|---|---|
| Plaintext amount tampering | ✅ Amount stored securely on backend |
| URL parameter hijacking | ✅ Only session ID in deeplink |
| Man-in-the-middle attacks | ✅ Direct wallet-to-backend communication |
| Replay attacks | ✅ Nonce and timestamp validation |
| Data integrity | ✅ Optional cryptographic signatures |
Complete Flow Summary
- H5 app creates payment session → receives
sessionId - H5 app launches wallet with deeplink containing only
sessionId - Wallet app extracts
sessionIdfrom deeplink - Wallet app fetches payment data from your backend using
sessionId - User confirms payment in wallet interface
- Wallet app executes transaction and reports back to backend
- Backend marks session as completed
Practical Tips
- Keep sessions short-lived: 2-5 minutes is usually sufficient
- Implement proper logging: Track all payment sessions for debugging
- Add rate limiting: Prevent abuse of session creation endpoints
- Use HTTPS everywhere: Ensure all API communications are encrypted
- Consider wallet-specific deeplink formats: Different wallets may have different URL schemes
Summary
By implementing this session-based approach, you've created a significantly more secure payment flow that eliminates the risks associated with plaintext deeplinks. The wallet now fetches trusted payment data directly from your backend rather than relying on potentially tampered URL parameters.
This minimal implementation provides a solid foundation that you can extend with additional features like multi-signature support, advanced fraud detection, or integration with existing payment processors. The key principle remains the same: never trust sensitive data that comes through client-side channels when server-side verification is possible.
