Skip to main content

Security Best Practices

Complete security guide for Wallet API integration.

Critical

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:

  1. Credential Management - How to securely store API credentials
  2. HTTPS & Certificates - Enforcing secure connections
  3. Token Security - Managing access tokens safely
  4. API Request Security - Validating and securing API calls
  5. Webhook Security - Verifying callback authenticity
  6. Data Protection - Protecting sensitive information
  7. Common Mistakes - What to avoid
  8. Mobile Apps - Special considerations for mobile

1. Credential Management

Always use environment variables - never hardcode credentials.

✅ CORRECT - Environment Variables
// .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');
}
❌ HARDCODED
// NEVER do this!
$clientId = 'wkVd93h2uS';
$macKey = 'IrdTc8uQodU7PRpLzzLTW6wqZAO6tAMU'; // EXPOSED!
Hardcoding credentials in code
❌ BAD
class PayseraClient {
private $clientId = 'wkVd93h2uS';
private $macKey = 'IrdTc8uQodU7PRpLzzLTW6wqZAO6tAMU';
}
✅ GOOD
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
❌ BAD
error_log("API Request: " . json_encode([
'client_id' => $clientId,
'mac_key' => $macKey // EXPOSED IN LOGS!
]));
✅ GOOD
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.

✅ CORRECT
// 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 DO THIS
// NEVER disable SSL verification in production!
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);

Certificate Pinning for Mobile Apps:

For mobile applications, implement certificate pinning to prevent man-in-the-middle attacks:

// iOS - Pin Paysera certificates
let serverTrustPolicy = PinnedCertificatesTrustEvaluator(
certificates: [
Certificates.payseraCert
]
)

let manager = ServerTrustManager(
evaluators: ["wallet.paysera.com": serverTrustPolicy]
)

3. Token Security

Secure Token Storage:

✅ CORRECT - Secure Session
// 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;
❌ INSECURE
// Don't store tokens in plain cookies or localStorage!
setcookie('token', $accessToken); // BAD!
// localStorage.setItem('token', token); // BAD!

Token Refresh Implementation:

✅ CORRECT - With Expiration Buffer
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:

✅ CORRECT - Comprehensive 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;
}
❌ NO VALIDATION
// Don't trust user input!
function createTransaction($data) {
return $this->api->create($data); // DANGEROUS!
}

Rate Limiting:

✅ CORRECT - Redis-based Rate Limiter
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 payload
  • sign - base64-encoded RSA signature of the raw event string, 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:

CORRECT - Verify with openssl_verify
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;
}
INCORRECT - Do not invent your own header / HMAC scheme
// 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
Use a library when available

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:

✅ CORRECT - Automatic Redaction
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:

✅ CORRECT - Mask Personal Data
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
]);
❌ EXPOSED PII
// 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:

❌ NEVER DO THIS
// 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:

❌ BAD
// Exposed in git, deployed to production
const credentials = {
clientId: 'wkVd93h2uS',
macKey: 'IrdTc8uQodU7PRpLzzLTW6wqZAO6tAMU'
};

Solution: Use environment variables.

3. Not Validating Webhooks:

❌ DANGEROUS
// 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:

❌ BAD
// Credentials in logs
error_log("Request: " . json_encode($_POST)); // May contain tokens!

Solution: Redact sensitive fields before logging.

5. Using HTTP Instead of HTTPS:

❌ INSECURE
const url = 'http://wallet.paysera.com/rest/v1/user/me';

Solution: Always use https:// - API will reject HTTP anyway.


Implement Proper Error Handling:

✅ CORRECT - Generic User Messages
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);
}
❌ EXPOSED ERROR DETAILS
// 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:

❌ WRONG Architecture
Mobile App → Paysera API (credentials exposed!)
✅ CORRECT Architecture
Mobile App → Your Backend → Paysera API (secure!)

Secure Token Storage:

✅ CORRECT - iOS Keychain
// Store in Keychain, not UserDefaults
KeychainWrapper.standard.set(
token,
forKey: "paysera_token",
withAccessibility: .whenUnlockedThisDeviceOnly
)
❌ INSECURE - iOS
// Don't use UserDefaults for tokens!
UserDefaults.standard.set(token, forKey: "token") // INSECURE!
✅ CORRECT - Android Encrypted Storage
// 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()
❌ INSECURE - Android
// Don't use plain SharedPreferences for tokens!
val prefs = context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
prefs.edit().putString("token", token).apply() // INSECURE!

Certificate Pinning:

✅ CORRECT - Certificate Pinning
// iOS with Alamofire
let evaluators: [String: ServerTrustEvaluating] = [
"wallet.paysera.com": PinnedCertificatesTrustEvaluator()
]

let manager = ServerTrustManager(evaluators: evaluators)

Additional Resources
Production Security

Before launching to production, complete the security checklist and conduct a security audit. Contact Paysera support for security consultations.