Security Best Practices
Complete security guide for Wallet API integration.
Following these security practices is mandatory for production use. Failure to implement proper security measures may result in:
- Unauthorized access to user accounts
- Financial losses
- Data breaches
- API access revocation
Overview
This guide covers:
- Credential Management - How to securely store API credentials
- HTTPS & Certificates - Enforcing secure connections
- Token Security - Managing access tokens safely
- API Request Security - Validating and securing API calls
- Webhook Security - Verifying callback authenticity
- Data Protection - Protecting sensitive information
- Common Mistakes - What to avoid
- Mobile Apps - Special considerations for mobile
1. Credential Management
Always use environment variables - never hardcode credentials.
- PHP
- JavaScript
- Python
// .env file (NEVER commit to git!)
PAYSERA_CLIENT_ID=your_client_id
PAYSERA_MAC_KEY=your_mac_key
// .gitignore
.env
.env.*
!.env.example
// Load in code
$clientId = getenv('PAYSERA_CLIENT_ID');
$macKey = getenv('PAYSERA_MAC_KEY');
if (!$clientId || !$macKey) {
throw new Exception('Missing Paysera credentials');
}
// NEVER do this!
$clientId = 'wkVd93h2uS';
$macKey = 'IrdTc8uQodU7PRpLzzLTW6wqZAO6tAMU'; // EXPOSED!
// .env file
PAYSERA_CLIENT_ID=your_client_id
PAYSERA_MAC_KEY=your_mac_key
// Load with dotenv
require('dotenv').config();
const credentials = {
clientId: process.env.PAYSERA_CLIENT_ID,
macKey: process.env.PAYSERA_MAC_KEY
};
if (!credentials.clientId || !credentials.macKey) {
throw new Error('Missing Paysera credentials');
}
// NEVER do this!
const credentials = {
clientId: 'wkVd93h2uS',
macKey: 'IrdTc8uQodU7PRpLzzLTW6wqZAO6tAMU' // EXPOSED!
};
# .env file
PAYSERA_CLIENT_ID=your_client_id
PAYSERA_MAC_KEY=your_mac_key
# Load with python-dotenv
from dotenv import load_dotenv
import os
load_dotenv()
CLIENT_ID = os.getenv('PAYSERA_CLIENT_ID')
MAC_KEY = os.getenv('PAYSERA_MAC_KEY')
if not CLIENT_ID or not MAC_KEY:
raise ValueError('Missing Paysera credentials')
# NEVER do this!
CLIENT_ID = 'wkVd93h2uS'
MAC_KEY = 'IrdTc8uQodU7PRpLzzLTW6wqZAO6tAMU' # EXPOSED!
❌ Hardcoding credentials in code
class PayseraClient {
private $clientId = 'wkVd93h2uS';
private $macKey = 'IrdTc8uQodU7PRpLzzLTW6wqZAO6tAMU';
}
class PayseraClient {
private $clientId;
private $macKey;
public function __construct() {
$this->clientId = getenv('PAYSERA_CLIENT_ID');
$this->macKey = getenv('PAYSERA_MAC_KEY');
}
}
❌ Committing credentials to git
# Check if you accidentally committed secrets
git log -p | grep -i "mac_key\|client_id"
# If found, use git-filter-repo to remove
# See: https://github.com/newren/git-filter-repo
❌ Logging credentials
error_log("API Request: " . json_encode([
'client_id' => $clientId,
'mac_key' => $macKey // EXPOSED IN LOGS!
]));
error_log("API Request: " . json_encode([
'client_id' => $clientId,
'mac_key' => '***REDACTED***'
]));
2. HTTPS & Certificates
All API communication must use HTTPS with proper certificate validation.
- PHP
- JavaScript
- Python
// Verify SSL certificates
$ch = curl_init('https://wallet.paysera.com/rest/v1/user/me');
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
// NEVER disable SSL verification in production!
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
// Node.js - certificates verified by default
const https = require('https');
const options = {
hostname: 'wallet.paysera.com',
port: 443,
path: '/rest/v1/user/me',
method: 'GET',
// rejectUnauthorized: true is default
};
https.request(options, callback);
// Don't disable certificate verification!
const options = {
rejectUnauthorized: false // DANGEROUS!
};
import requests
# Verify SSL (default behavior)
response = requests.get(
'https://wallet.paysera.com/rest/v1/user/me',
verify=True
)
# Don't disable SSL verification!
response = requests.get(url, verify=False)
Certificate Pinning for Mobile Apps:
For mobile applications, implement certificate pinning to prevent man-in-the-middle attacks:
- iOS
- Android
// iOS - Pin Paysera certificates
let serverTrustPolicy = PinnedCertificatesTrustEvaluator(
certificates: [
Certificates.payseraCert
]
)
let manager = ServerTrustManager(
evaluators: ["wallet.paysera.com": serverTrustPolicy]
)
// Android - Configure OkHttp with certificate pinner
val certificatePinner = CertificatePinner.Builder()
.add("wallet.paysera.com", "sha256/...")
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()
3. Token Security
Secure Token Storage:
- Web Application
- Mobile App
// Store in secure session with proper flags
session_start([
'cookie_httponly' => true,
'cookie_secure' => true, // HTTPS only
'cookie_samesite' => 'Lax'
]);
$_SESSION['paysera_token'] = $accessToken;
$_SESSION['paysera_token_expires'] = time() + 3600;
// Don't store tokens in plain cookies or localStorage!
setcookie('token', $accessToken); // BAD!
// localStorage.setItem('token', token); // BAD!
iOS (Keychain):
// Save to Keychain
KeychainWrapper.standard.set(
accessToken,
forKey: "paysera_access_token"
)
// Retrieve
if let token = KeychainWrapper.standard.string(
forKey: "paysera_access_token"
) {
// Use token
}
Android (EncryptedSharedPreferences):
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val prefs = EncryptedSharedPreferences.create(
context,
"paysera_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
prefs.edit()
.putString("access_token", accessToken)
.apply()
Token Refresh Implementation:
function getValidToken() {
$token = $_SESSION['paysera_token'] ?? null;
$expires = $_SESSION['paysera_token_expires'] ?? 0;
// Check if token is expired (5 min buffer)
if (!$token || time() >= ($expires - 300)) {
$refreshToken = $_SESSION['paysera_refresh_token'];
// Refresh the token
$newTokens = $this->refreshAccessToken($refreshToken);
$_SESSION['paysera_token'] = $newTokens['access_token'];
$_SESSION['paysera_token_expires'] = time() + $newTokens['expires_in'];
return $newTokens['access_token'];
}
return $token;
}
4. API Request Security
Input Validation:
function createTransaction($data) {
// Validate required fields
$required = ['amount', 'currency', 'description'];
foreach ($required as $field) {
if (!isset($data[$field]) || empty($data[$field])) {
throw new InvalidArgumentException("Missing required field: {$field}");
}
}
// Validate amount
if (!is_numeric($data['amount']) || $data['amount'] <= 0) {
throw new InvalidArgumentException('Invalid amount');
}
// Validate currency
$validCurrencies = ['EUR', 'USD', 'GBP'];
if (!in_array($data['currency'], $validCurrencies)) {
throw new InvalidArgumentException('Invalid currency');
}
// Sanitize description
$data['description'] = strip_tags($data['description']);
$data['description'] = substr($data['description'], 0, 255);
return $data;
}
// Don't trust user input!
function createTransaction($data) {
return $this->api->create($data); // DANGEROUS!
}
Rate Limiting:
class RateLimiter {
private $redis;
private $maxRequests = 60;
private $window = 60; // seconds
public function checkLimit($clientId) {
$key = "rate_limit:{$clientId}";
$count = $this->redis->incr($key);
if ($count === 1) {
$this->redis->expire($key, $this->window);
}
if ($count > $this->maxRequests) {
$ttl = $this->redis->ttl($key);
throw new RateLimitException(
"Rate limit exceeded. Retry after {$ttl} seconds"
);
}
return true;
}
}
5. Callback (Webhook) Security
Paysera delivers transaction events to your callback_uri as an application/x-www-form-urlencoded POST with two fields:
event- JSON-encoded event payloadsign- base64-encoded RSA signature of the raweventstring, produced with Paysera's private key using SHA-256
You verify the signature with Paysera's public key, available at:
https://wallet.paysera.com/publickey
Signature Verification:
- PHP
- JavaScript
function verifyPayseraCallback(string $publicKeyPem): array {
$event = $_POST['event'] ?? '';
$sign = $_POST['sign'] ?? '';
if ($event === '' || $sign === '') {
http_response_code(400);
die('Missing event or sign');
}
// Verify the raw event string against the base64-decoded signature
$ok = openssl_verify(
$event,
base64_decode($sign),
$publicKeyPem,
OPENSSL_ALGO_SHA256
);
if ($ok !== 1) {
http_response_code(401);
die('Invalid signature');
}
$data = json_decode($event, true);
if (!is_array($data)) {
http_response_code(400);
die('Invalid JSON payload');
}
return $data;
}
// Paysera does NOT send an X-Paysera-Signature header
// and does NOT use a shared HMAC secret for callbacks.
$signature = $_SERVER['HTTP_X_PAYSERA_SIGNATURE'] ?? ''; // does not exist
$expected = hash_hmac('sha256', $body, $sharedSecret); // wrong scheme
const crypto = require('crypto');
function verifyPayseraCallback(req, publicKeyPem) {
const event = req.body.event;
const sign = req.body.sign;
if (!event || !sign) {
throw new Error('Missing event or sign');
}
const verifier = crypto.createVerify('SHA256');
verifier.update(event);
verifier.end();
const ok = verifier.verify(publicKeyPem, Buffer.from(sign, 'base64'));
if (!ok) {
throw new Error('Invalid signature');
}
return JSON.parse(event);
}
Where an official Paysera library exists for your language, prefer its built-in callback verification helper instead of implementing the RSA verification yourself.
Validating the event body:
After verifying the signature, also check that the object field is what you expect (currently always transaction) so your handler stays compatible with future event types, and that the type matches one of the documented values: rejected, failed, reserved, confirmed, waiting_funds, waiting_registration, waiting_password.
Response status:
Return 200 (or any 2xx) once you have processed the callback successfully. Any other status causes Paysera to retry later. Do not respond with 3xx redirects. The transaction state on Paysera's side is independent of your response code, so always make your handler idempotent against repeated deliveries of the same event.
6. Data Protection
Logging Without Sensitive Data:
class SecureLogger {
private $sensitiveFields = [
'mac_key',
'access_token',
'refresh_token',
'password',
'pin',
'cvv',
'card_number'
];
public function log($level, $message, $context = []) {
// Redact sensitive fields
$safeContext = $this->redactSensitive($context);
error_log(sprintf(
"[%s] %s %s",
$level,
$message,
json_encode($safeContext)
));
}
private function redactSensitive($data) {
if (is_array($data)) {
foreach ($data as $key => $value) {
if (in_array(strtolower($key), $this->sensitiveFields)) {
$data[$key] = '***REDACTED***';
} elseif (is_array($value)) {
$data[$key] = $this->redactSensitive($value);
}
}
}
return $data;
}
}
PII Masking:
function maskEmail($email) {
$parts = explode('@', $email);
$username = $parts[0];
$domain = $parts[1] ?? '';
$maskedUsername = substr($username, 0, 2) .
str_repeat('*', strlen($username) - 2);
return $maskedUsername . '@' . $domain;
}
function maskPhone($phone) {
return substr($phone, 0, 3) .
str_repeat('*', strlen($phone) - 6) .
substr($phone, -3);
}
// Example usage in logs
$logger->log('info', 'User updated', [
'user_id' => $userId,
'email' => maskEmail($user->email), // jo***@example.com
'phone' => maskPhone($user->phone) // +37*******123
]);
// Don't log personal information unmasked!
$logger->log('info', 'User updated', [
'email' => $user->email, // john.doe@example.com - EXPOSED!
'phone' => $user->phone // +37061234567 - EXPOSED!
]);
7. Common Security Mistakes
1. Disabling SSL Verification:
// Even for testing!
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
Why it's dangerous: Opens you to man-in-the-middle attacks.
Solution: Fix certificate issues properly instead of disabling verification.
2. Storing Credentials in Code:
// Exposed in git, deployed to production
const credentials = {
clientId: 'wkVd93h2uS',
macKey: 'IrdTc8uQodU7PRpLzzLTW6wqZAO6tAMU'
};
Solution: Use environment variables.
3. Not Validating Webhooks:
// No signature check!
$data = json_decode(file_get_contents('php://input'), true);
$this->processPayment($data); // Anyone can trigger this!
Solution: Always verify webhook signatures.
4. Logging Sensitive Data:
// Credentials in logs
error_log("Request: " . json_encode($_POST)); // May contain tokens!
Solution: Redact sensitive fields before logging.
5. Using HTTP Instead of HTTPS:
const url = 'http://wallet.paysera.com/rest/v1/user/me';
Solution: Always use https:// - API will reject HTTP anyway.
Implement Proper Error Handling:
try {
$transaction = $api->createTransaction($data);
} catch (UnauthorizedException $e) {
// Don't expose: "Invalid MAC signature"
$logger->error('API authentication failed', [
'error' => $e->getMessage()
]);
// Show to user: Generic message
return response('Payment service unavailable', 503);
} catch (RateLimitException $e) {
return response('Too many requests, try again later', 429);
} catch (Exception $e) {
$logger->error('Payment failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return response('Payment processing error', 500);
}
// Don't expose internal errors to users!
try {
$transaction = $api->createTransaction($data);
} catch (Exception $e) {
return response($e->getMessage(), 500); // Exposes internals!
}
Monitor API Usage:
// Track API metrics
class ApiMetrics {
public function recordRequest($endpoint, $status, $duration) {
$this->metrics->increment('api.requests', [
'endpoint' => $endpoint,
'status' => $status
]);
$this->metrics->timing('api.duration', $duration, [
'endpoint' => $endpoint
]);
// Alert on high error rate
if ($status >= 500) {
$this->alertOnError($endpoint, $status);
}
}
}
Keep Dependencies Updated:
# Check for security updates
composer audit
# Update dependencies
composer update
# Lock versions in production
composer install --no-dev
8. Mobile App Security
Backend Proxy Pattern:
Never make API calls directly from mobile apps:
Mobile App → Paysera API (credentials exposed!)
Mobile App → Your Backend → Paysera API (secure!)
Secure Token Storage:
// Store in Keychain, not UserDefaults
KeychainWrapper.standard.set(
token,
forKey: "paysera_token",
withAccessibility: .whenUnlockedThisDeviceOnly
)
// Don't use UserDefaults for tokens!
UserDefaults.standard.set(token, forKey: "token") // INSECURE!
// Use EncryptedSharedPreferences
val prefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
prefs.edit()
.putString("access_token", accessToken)
.apply()
// Don't use plain SharedPreferences for tokens!
val prefs = context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
prefs.edit().putString("token", token).apply() // INSECURE!
Certificate Pinning:
// iOS with Alamofire
let evaluators: [String: ServerTrustEvaluating] = [
"wallet.paysera.com": PinnedCertificatesTrustEvaluator()
]
let manager = ServerTrustManager(evaluators: evaluators)
Additional Resources
- 📖 Authentication - Secure authentication methods
- 🔐 API Fundamentals - Error handling and best practices
- 💡 Making Your First Request - Secure implementation examples
Before launching to production, complete the security checklist and conduct a security audit. Contact Paysera support for security consultations.