Billing software sits at the intersection of two things attackers love most: money and data. If you're running a PHP billing application for self-storage or any other industry, you're handling payment card data, personally identifiable information, and recurring financial transactions. A single vulnerability doesn't just expose a record — it exposes a revenue stream. I've spent years building and hardening billing platforms, and this post covers the baseline security controls every PHP billing application needs before it goes anywhere near production.
SQL Injection: Prepared Statements Are Non-Negotiable
I still find raw query string interpolation in production billing code. It's 2024 and this keeps happening. SQL injection in a billing application isn't an abstract risk — it's an attacker dumping your entire payment history or modifying ledger records without leaving a trace in your application logs.
The distinction between parameterized queries and prepared statements is worth being precise about. A parameterized query sends the query structure and the data separately to the database engine. The engine never interpolates user input into SQL. Prepared statements are the mechanism — you prepare once, bind parameters, execute. Here's the pattern I use for every billing query:
// Bad — never do this in billing code
$stmt = "SELECT * FROM payments WHERE tenant_id = '$tenant_id' AND status = '$status'";
$result = mysqli_query($conn, $stmt);
// Correct — parameterized with PDO
$pdo = new PDO('mysql:host=localhost;dbname=billing', $user, $pass, [
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$stmt = $pdo->prepare(
'SELECT payment_id, amount, created_at FROM payments
WHERE tenant_id = :tenant_id AND status = :status'
);
$stmt->execute([':tenant_id' => $tenantId, ':status' => $status]);
$payments = $stmt->fetchAll(PDO::FETCH_ASSOC);
Note PDO::ATTR_EMULATE_PREPARES => false. With emulation enabled, PDO falls back to string interpolation for certain drivers. Disable it. Also ensure your MySQL user account for the billing application has only SELECT, INSERT, UPDATE on the tables it legitimately needs — no DROP, no GRANT, no FILE.
PCI-DSS Scope for Storage Billing
The first question is always: are you storing card data? In most modern storage billing setups the answer should be no, and that's by design. If you're using a payment gateway — Stripe, Authorize.Net, NMI — and using their tokenization, the raw card number never touches your server. Your PCI scope collapses to SAQ A or SAQ A-EP rather than the full SAQ D nightmare.
What this means architecturally: your billing platform stores a gateway token (a string like tok_1NqW2... or a CIM profile ID), not a PAN. When you need to charge a tenant, you pass the token to the gateway. The gateway holds the card data in their PCI-certified vault. Your responsibility is protecting that token with the same care you'd protect the card number, because it's functionally equivalent in terms of what an attacker can do with it.
Where storage operators commonly increase their PCI scope without realizing it: saving card images for "verification," logging full POST bodies that include card fields, or building a custom checkout form that POSTs to your server before forwarding to the gateway. Use hosted payment pages or gateway JavaScript SDKs that tokenize on the client side. The card number should never be a value your PHP application reads from $_POST.
Token-Based Authentication with Short-Lived JWTs
Billing API endpoints need stateless, auditable authentication. Session cookies work for browser-based admin UIs but break down for API integrations between your billing platform and facility management software. JWTs with short expiry windows and a refresh token pattern give you both.
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
// Issuing a token at login
function issueAuthToken(int $userId, string $role): array
{
$now = time();
$accessPayload = [
'iss' => 'billing.yourdomain.com',
'sub' => $userId,
'role' => $role,
'iat' => $now,
'exp' => $now + 900, // 15 minutes
];
$accessToken = JWT::encode($accessPayload, $_ENV['JWT_SECRET'], 'HS256');
// Refresh token stored in DB with hashed value
$refreshToken = bin2hex(random_bytes(40));
storeRefreshToken($userId, hash('sha256', $refreshToken));
return ['access_token' => $accessToken, 'refresh_token' => $refreshToken];
}
// Validating on protected endpoints
function requireAuth(): array
{
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer (.+)$/', $header, $m)) {
http_response_code(401); exit;
}
try {
return (array) JWT::decode($m[1], new Key($_ENV['JWT_SECRET'], 'HS256'));
} catch (\Exception $e) {
http_response_code(401); exit;
}
}
Keep access token expiry at 15 minutes or less. A stolen access token has a hard shelf life. Refresh tokens should be single-use (rotate on each use) and stored as hashes in your database so a DB compromise doesn't immediately yield usable tokens.
Rate Limiting Payment Endpoints
Payment endpoints are brute-force targets. Attackers use billing APIs to validate stolen card numbers — they try small charges in bulk hoping a few succeed. Without rate limiting, your gateway account gets flagged and your tenants start seeing unexpected charges.
Implement rate limiting at two levels: per IP and per account. A Redis-backed token bucket works well:
function checkRateLimit(string $key, int $maxRequests, int $windowSeconds): bool
{
$redis = getRedis();
$current = $redis->incr($key);
if ($current === 1) {
$redis->expire($key, $windowSeconds);
}
return $current <= $maxRequests;
}
// In your payment processing endpoint
$ipKey = 'rate:ip:' . hash('sha256', $_SERVER['REMOTE_ADDR']);
$userKey = 'rate:user:' . $authenticatedUserId;
if (!checkRateLimit($ipKey, 5, 60) || !checkRateLimit($userKey, 10, 300)) {
http_response_code(429);
header('Retry-After: 60');
exit(json_encode(['error' => 'Too many requests']));
}
Five payment attempts per IP per minute is generous for legitimate use. If a tenant portal allows payment method updates, limit those even more aggressively. Log every rate limit trigger — a burst of 429s on your payment endpoint is an active attack signal.
Audit Logging for Billing Events
Every mutation to financial data needs an immutable audit trail. This isn't optional in billing software — it's how you resolve disputes, satisfy accountants, and comply with PCI requirement 10. Your audit log table should be append-only with no application-level DELETE or UPDATE access:
CREATE TABLE billing_audit_log (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
actor_id INT UNSIGNED,
actor_role VARCHAR(32),
tenant_id INT UNSIGNED,
entity_type VARCHAR(64),
entity_id INT UNSIGNED,
old_value JSON,
new_value JSON,
ip_address VARCHAR(45),
user_agent VARCHAR(255),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX idx_tenant (tenant_id),
INDEX idx_created (created_at)
) ENGINE=InnoDB;
Log every payment attempt (success and failure), every invoice modification, every credit applied, every rate change, and every user login to the billing admin panel. The MySQL user your application connects with should have INSERT but not UPDATE or DELETE on this table.
CSRF Protection on Payment Forms
Cross-site request forgery on a payment form means an attacker can trick an authenticated tenant into submitting a payment or changing their payment method by getting them to load a malicious page. Synchronizer tokens are the standard fix:
// Generate and store token in session
function generateCsrfToken(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
// Emit in your form template
echo '<input type="hidden" name="csrf_token" value="'
. htmlspecialchars(generateCsrfToken(), ENT_QUOTES, 'UTF-8') . '">';
// Validate on POST
function verifyCsrfToken(): void
{
$submitted = $_POST['csrf_token'] ?? '';
$expected = $_SESSION['csrf_token'] ?? '';
if (!hash_equals($expected, $submitted)) {
http_response_code(403);
exit('Invalid CSRF token');
}
}
Use hash_equals for the comparison — it's timing-safe. String equality operators short-circuit on first mismatch and leak timing information that could theoretically be exploited. Regenerate the CSRF token after each successful payment form submission.
Putting It Together
Security in billing software is not a feature you add at the end — it's a constraint that shapes the architecture from the first query you write. The controls covered here form a baseline: parameterized queries eliminate the most common injection vectors, PCI tokenization keeps card data out of your scope, short-lived JWTs contain the damage from stolen credentials, rate limiting blocks card-testing attacks, audit logs give you forensic visibility, and CSRF tokens protect payment forms from cross-origin abuse.
Beyond these basics, run your billing endpoints through OWASP ZAP regularly, keep your PHP and library dependencies current, and ensure your production server disables display_errors — stack traces in payment error responses have leaked database schema details in more real-world incidents than I can count. Build the security baseline in from day one and you won't be retrofitting it under pressure after an incident.