Implementing Defense-in-Depth License Validation in PHP Applications
Introduction
PHP developers working without frameworks face unique challenges when implementing license validation. Without middleware systems, service containers, or routing abstractions, every validation point must be manually implemented.
This guide teaches you how to build layered, resilient license enforcement in plain PHP applications—whether you’re building a custom CMS, a legacy application, an API server, or a standalone tool. You’ll learn where to place validation logic, how to structure include guards, how to manage state properly, and how to avoid common pitfalls.
If you’re maintaining PHP code without Laravel, Symfony, or WordPress, this guide will help you implement license validation that withstands real-world tampering attempts while remaining maintainable.
Common Mistakes Developers Make
Let’s understand why naive PHP integrations fail.
Mistake 1: Single Include File Check
<?php
// config.php
require_once 'entitlement-check.php';
if (!verifyAuthority()) {
die('Unauthorized');
}
?>
Why this fails: If attackers remove the require_once statement or comment out the check in config.php, they bypass all protection. A single include point is easily defeated.
Mistake 2: Boolean-Based Validation
<?php
$hasAuthority = checkEntitlement();
if ($hasAuthority === true) {
// Allow access to premium features
}
?>
Why this fails: Booleans are trivial to forge. Attackers search for $hasAuthority and set it to true. No context about why validation failed.
Mistake 3: Storing Keys in Plain Text Files
<?php
// config.php
define('ENTITLEMENT_KEY', 'XXXX-XXXX-XXXX-XXXX');
?>
Why this fails: Configuration files are readable by anyone with file system access. Keys should be stored encrypted or in databases.
Mistake 4: Relying Only on Sessions
<?php
session_start();
if (empty($_SESSION['authority_verified'])) {
$_SESSION['authority_verified'] = true; // Set without verification!
}
?>
Why this fails: Session variables can be manipulated. Attackers can set session variables directly or modify session files.
Mistake 5: No Validation in Included Files
<?php
// premium-feature.php - No validation!
function generateReport() {
// Assumes validation happened elsewhere
return buildReport();
}
?>
Why this fails: If someone directly includes premium-feature.php, they bypass all validation. Each file must protect itself.
Mistake 6: Hard-Coding Secret Values
<?php
$api_key = 'your-api-key-here'; // Visible in source code!
?>
Why this fails: Hardcoded values are visible to anyone who can read the PHP files.
Where to Add Enforcement Logic
Plain PHP applications require validation at strategic points:
1. Application Bootstrap (index.php or init.php)
Location: Main entry point
Purpose: Initialize entitlement state for the session.
<?php
// index.php
// Start session
session_start();
// Load configuration
require_once __DIR__ . '/config/config.php';
// Load entitlement service
require_once __DIR__ . '/lib/EntitlementService.php';
// Initialize entitlement service
$entitlementService = EntitlementService::getInstance();
$entitlementService->initialize();
// Route to appropriate page
$page = $_GET['page'] ?? 'home';
$page = basename($page); // Security: prevent directory traversal
switch ($page) {
case 'dashboard':
require_once __DIR__ . '/pages/dashboard.php';
break;
case 'export':
require_once __DIR__ . '/pages/export.php';
break;
default:
require_once __DIR__ . '/pages/home.php';
}
?>
Why here: Central initialization ensures state is prepared before any page loads.
2. Include Guards (At Top of Every Protected File)
Location: Top of PHP files requiring validation
Purpose: Prevent direct access to individual files.
<?php
// pages/premium-feature.php
// Prevent direct access
if (!defined('APP_INIT')) {
die('Direct access not permitted');
}
// Verify authority before rendering
require_once __DIR__ . '/../lib/EntitlementService.php';
$entitlementService = EntitlementService::getInstance();
$state = $entitlementService->getState();
if ($state['status'] !== 'ACTIVE') {
header('Location: /activate.php');
exit;
}
if (!in_array('premium_feature', $state['features'] ?? [], true)) {
die('This feature is not available in your plan.');
}
// Render premium feature
?>
<html>
<body>
<h1>Premium Feature</h1>
<!-- Premium content -->
</body>
</html>
Why here: Each file validates independently, preventing bypass through direct URL access.
3. API Endpoints (AJAX Handlers)
Location: API files handling AJAX requests
Purpose: Protect data endpoints from unauthorized access.
<?php
// api/export.php
header('Content-Type: application/json');
// Check request method
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
// Load entitlement service
require_once __DIR__ . '/../lib/EntitlementService.php';
$entitlementService = EntitlementService::getInstance();
$state = $entitlementService->getState();
// Verify authority
if ($state['status'] !== 'ACTIVE') {
http_response_code(403);
echo json_encode([
'error' => 'Access denied',
'reason' => $state['status']
]);
exit;
}
// Check export capability
if (!in_array('export', $state['features'] ?? [], true)) {
http_response_code(403);
echo json_encode([
'error' => 'Export capability not available'
]);
exit;
}
// Perform export
$data = performExport();
echo json_encode([
'success' => true,
'data' => $data
]);
?>
Why here: API endpoints can be called directly via JavaScript or curl, so they must validate independently.
4. Cron Jobs (Scheduled Tasks)
Location: Cron script files
Purpose: Ensure background tasks respect entitlement state.
<?php
// cron/daily-report.php
// Set execution time limit
set_time_limit(300);
// Load configuration and services
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../lib/EntitlementService.php';
$entitlementService = EntitlementService::getInstance();
$state = $entitlementService->resolveAuthority();
// Check if automated reports capability is available
if ($state['status'] !== 'ACTIVE') {
error_log('Daily report skipped: Entitlement not active');
exit(1);
}
if (!in_array('automated_reports', $state['features'] ?? [], true)) {
error_log('Daily report skipped: Automated reports capability not available');
exit(1);
}
// Generate report
try {
$report = generateDailyReport();
sendReportEmail($report);
error_log('Daily report completed successfully');
} catch (Exception $e) {
error_log('Daily report failed: ' . $e->getMessage());
exit(1);
}
?>
Why here: Cron jobs run independently and must validate on each execution.
5. Form Processing Scripts
Location: Form handlers
Purpose: Validate before processing sensitive form submissions.
<?php
// process/save-settings.php
session_start();
// Load entitlement service
require_once __DIR__ . '/../lib/EntitlementService.php';
// Check if form was submitted
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /settings.php');
exit;
}
// Verify CSRF token
if (empty($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
die('Invalid request');
}
$entitlementService = EntitlementService::getInstance();
$state = $entitlementService->getState();
// Check if advanced settings capability is available
if (!in_array('advanced_settings', $state['features'] ?? [], true)) {
$_SESSION['error'] = 'Advanced settings not available in your plan';
header('Location: /settings.php');
exit;
}
// Process form data
$settings = [
'option1' => $_POST['option1'] ?? '',
'option2' => $_POST['option2'] ?? ''
];
saveAdvancedSettings($settings);
$_SESSION['success'] = 'Settings saved successfully';
header('Location: /settings.php');
exit;
?>
Why here: Form processing must validate independently of the rendering logic.
6. Download Scripts
Location: File download handlers
Purpose: Protect file downloads behind entitlement checks.
<?php
// download.php
session_start();
// Get file parameter
$file = $_GET['file'] ?? '';
$file = basename($file); // Security: prevent directory traversal
if (empty($file)) {
die('No file specified');
}
// Load entitlement service
require_once __DIR__ . '/lib/EntitlementService.php';
$entitlementService = EntitlementService::getInstance();
$state = $entitlementService->getState();
// Verify authority
if ($state['status'] !== 'ACTIVE') {
die('Access denied: Entitlement not active');
}
// Check download capability
if (!in_array('downloads', $state['features'] ?? [], true)) {
die('Download capability not available');
}
// Serve file
$filepath = __DIR__ . '/files/' . $file;
if (!file_exists($filepath)) {
http_response_code(404);
die('File not found');
}
// Set headers
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $file . '"');
header('Content-Length: ' . filesize($filepath));
// Output file
readfile($filepath);
exit;
?>
Why here: Download scripts must validate to prevent unauthorized file access.
What Data Should Be Passed
When validating in plain PHP:
Domain Name
<?php
// Get current domain
$domain = $_SERVER['HTTP_HOST'] ?? 'localhost';
// Or for more security
$domain = parse_url((isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'], PHP_URL_HOST);
$state = $entitlementService->resolveAuthority($entitlementKey, $domain);
?>
Why: Domain binding prevents license sharing across installations.
Server Identifier
<?php
// For VPS/dedicated servers
$identifier = gethostname() ?: 'unknown';
// Or use a stored server ID
$identifier = defined('SERVER_ID') ? SERVER_ID : 'server-1';
$state = $entitlementService->resolveAuthority($entitlementKey, $identifier);
?>
Why: Hardware/server identifiers work for non-shared hosting environments.
Environment Metadata
<?php
$metadata = [
'php_version' => PHP_VERSION,
'server_software' => $_SERVER['SERVER_SOFTWARE'] ?? 'unknown',
'app_version' => defined('APP_VERSION') ? APP_VERSION : '1.0.0'
];
$state = $entitlementService->resolveAuthority($entitlementKey, $identifier, $metadata);
?>
Why: Helps with diagnostics and support.
What Must Never Be Stored in Plain Text
❌ Never store unencrypted:
- Entitlement keys
- API keys
- Validation responses
✅ Use encryption:
<?php
// Simple encryption example (use better methods in production)
function encryptData($data, $key) {
$iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length('aes-256-cbc'));
$encrypted = openssl_encrypt($data, 'aes-256-cbc', $key, 0, $iv);
return base64_encode($encrypted . '::' . $iv);
}
function decryptData($data, $key) {
list($encrypted, $iv) = explode('::', base64_decode($data), 2);
return openssl_decrypt($encrypted, 'aes-256-cbc', $key, 0, $iv);
}
// Store encrypted entitlement key
$encryptionKey = defined('ENCRYPTION_KEY') ? ENCRYPTION_KEY : 'default-key';
$encryptedKey = encryptData($entitlementKey, $encryptionKey);
file_put_contents(__DIR__ . '/data/entitlement.enc', $encryptedKey);
?>
How to Structure Safe Checks
Plain PHP validation patterns:
Use State Objects, Not Booleans
<?php
// ❌ BAD
$hasAuthority = verifyEntitlement();
if ($hasAuthority) { }
// ✅ GOOD
$state = $entitlementService->getState();
if ($state['status'] === 'ACTIVE') {
// Proceed
}
if (in_array('export', $state['features'] ?? [], true)) {
// Show export button
}
?>
Why: State arrays provide context and enable granular checks.
Check Response Codes Explicitly
<?php
$response = $entitlementService->resolveAuthority($entitlementKey, $domain);
$code = $response['response']['code'] ?? null;
switch ($code) {
case 200:
// Valid and active
$_SESSION['entitlement_state'] = 'ACTIVE';
break;
case 205:
// Expired
$_SESSION['entitlement_state'] = 'EXPIRED';
header('Location: /renew.php');
exit;
case 204:
// Suspended
$_SESSION['entitlement_state'] = 'SUSPENDED';
header('Location: /suspended.php');
exit;
case 202:
// Not activated
$_SESSION['entitlement_state'] = 'NOT_ACTIVATED';
header('Location: /activate.php');
exit;
default:
// Unknown/invalid
$_SESSION['entitlement_state'] = 'INVALID';
header('Location: /activate.php');
exit;
}
?>
Why: Response codes provide precise failure reasons.
Cache in Session with Expiry
<?php
class EntitlementService {
private static $instance = null;
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function getState() {
// Check if we have cached state in session
if (!empty($_SESSION['entitlement_state']) &&
!empty($_SESSION['entitlement_expires']) &&
time() < $_SESSION['entitlement_expires']) {
return $_SESSION['entitlement_state'];
}
// Resolve authority
$state = $this->resolveAuthority();
// Cache for 1 hour
$_SESSION['entitlement_state'] = $state;
$_SESSION['entitlement_expires'] = time() + 3600;
return $state;
}
public function clearCache() {
unset($_SESSION['entitlement_state']);
unset($_SESSION['entitlement_expires']);
}
}
?>
Why: Session caching reduces API calls while maintaining reasonable freshness.
Validate at Multiple Points
<?php
// Point 1: In index.php (initialization)
$state = $entitlementService->getState();
if ($state['status'] !== 'ACTIVE') {
require_once 'pages/inactive.php';
exit;
}
// Point 2: In premium-feature.php (page-level)
if (!in_array('premium_feature', $state['features'] ?? [], true)) {
die('Feature not available');
}
// Point 3: In api/export.php (API endpoint)
$state = $entitlementService->getState();
if (!in_array('export', $state['features'] ?? [], true)) {
http_response_code(403);
echo json_encode(['error' => 'Export not available']);
exit;
}
?>
Why: Multiple validation points create defense in depth.
Example Integration Flow
Complete plain PHP integration:
Step 1: Entitlement Service Class
<?php
// lib/EntitlementService.php
class EntitlementService {
private static $instance = null;
private $apiKey;
private $productUuid;
private $apiUrl;
private function __construct() {
$this->apiKey = defined('ENTITLEMENT_API_KEY') ? ENTITLEMENT_API_KEY : '';
$this->productUuid = defined('ENTITLEMENT_PRODUCT_UUID') ? ENTITLEMENT_PRODUCT_UUID : '';
$this->apiUrl = defined('ENTITLEMENT_API_URL') ? ENTITLEMENT_API_URL : '';
}
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function initialize() {
if (!isset($_SESSION)) {
session_start();
}
// Load initial state
$this->getState();
}
public function resolveAuthority($entitlementKey = null, $identifier = null) {
$entitlementKey = $entitlementKey ?? $this->getStoredKey();
$identifier = $identifier ?? $_SERVER['HTTP_HOST'] ?? 'localhost';
if (empty($entitlementKey)) {
return ['status' => 'NOT_ACTIVATED'];
}
try {
$response = $this->makeApiRequest([
'entitlement_key' => $entitlementKey,
'identifier' => $identifier,
'product_uuid' => $this->productUuid
]);
return $this->parseResponse($response);
} catch (Exception $e) {
error_log('Authority resolution failed: ' . $e->getMessage());
return ['status' => 'DEGRADED'];
}
}
private function makeApiRequest($data) {
$ch = curl_init($this->apiUrl . '/verify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($data),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->apiKey
],
CURLOPT_TIMEOUT => 10
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new Exception('API request failed with code ' . $httpCode);
}
return json_decode($response, true);
}
private function parseResponse($response) {
$code = $response['response']['code'] ?? null;
$data = $response['response']['data'] ?? [];
switch ($code) {
case 200:
return [
'status' => 'ACTIVE',
'features' => $data['features'] ?? [],
'expires_at' => $data['expires_at'] ?? null
];
case 205:
return [
'status' => 'EXPIRED',
'expires_at' => $data['expires_at'] ?? null
];
case 204:
return ['status' => 'SUSPENDED'];
case 202:
return ['status' => 'NOT_ACTIVATED'];
default:
return ['status' => 'INVALID'];
}
}
public function getState() {
// Check session cache
if (!empty($_SESSION['entitlement_state']) &&
!empty($_SESSION['entitlement_expires']) &&
time() < $_SESSION['entitlement_expires']) {
return $_SESSION['entitlement_state'];
}
// Resolve authority
$state = $this->resolveAuthority();
// Cache for 1 hour
$_SESSION['entitlement_state'] = $state;
$_SESSION['entitlement_expires'] = time() + 3600;
return $state;
}
private function getStoredKey() {
// Load from database or encrypted file
$filepath = __DIR__ . '/../data/entitlement.enc';
if (!file_exists($filepath)) {
return null;
}
$encrypted = file_get_contents($filepath);
return $this->decrypt($encrypted);
}
private function decrypt($data) {
$key = defined('ENCRYPTION_KEY') ? ENCRYPTION_KEY : '';
list($encrypted, $iv) = explode('::', base64_decode($data), 2);
return openssl_decrypt($encrypted, 'aes-256-cbc', $key, 0, $iv);
}
}
?>
Step 2: Configuration File
<?php
// config/config.php
// Prevent direct access
if (!defined('APP_INIT')) {
define('APP_INIT', true);
}
// Load environment-specific configuration
$env = getenv('APP_ENV') ?: 'production';
if ($env === 'development') {
error_reporting(E_ALL);
ini_set('display_errors', 1);
} else {
error_reporting(0);
ini_set('display_errors', 0);
}
// Application constants
define('APP_VERSION', '1.0.0');
define('APP_ROOT', dirname(__DIR__));
// Entitlement API configuration
define('ENTITLEMENT_API_KEY', getenv('ENTITLEMENT_API_KEY'));
define('ENTITLEMENT_PRODUCT_UUID', getenv('ENTITLEMENT_PRODUCT_UUID'));
define('ENTITLEMENT_API_URL', getenv('ENTITLEMENT_API_URL') ?: 'https://api.getkeymanager.com/api/v1');
// Encryption key for storing sensitive data
define('ENCRYPTION_KEY', getenv('ENCRYPTION_KEY') ?: 'change-this-key');
?>
Step 3: Main Entry Point
<?php
// index.php
// Define initialization constant
define('APP_INIT', true);
// Start session
session_start();
// Load configuration
require_once __DIR__ . '/config/config.php';
// Load entitlement service
require_once __DIR__ . '/lib/EntitlementService.php';
// Initialize entitlement
$entitlementService = EntitlementService::getInstance();
$entitlementService->initialize();
// Get current state
$state = $entitlementService->getState();
// Route based on state
if ($state['status'] !== 'ACTIVE') {
require_once __DIR__ . '/pages/activate.php';
exit;
}
// Route to requested page
$page = $_GET['page'] ?? 'dashboard';
$page = preg_replace('/[^a-zA-Z0-9_-]/', '', $page); // Sanitize
$pagePath = __DIR__ . '/pages/' . $page . '.php';
if (file_exists($pagePath)) {
require_once $pagePath;
} else {
require_once __DIR__ . '/pages/404.php';
}
?>
Step 4: Protected Page
<?php
// pages/export.php
// Prevent direct access
if (!defined('APP_INIT')) {
die('Direct access not permitted');
}
// Verify authority
require_once __DIR__ . '/../lib/EntitlementService.php';
$entitlementService = EntitlementService::getInstance();
$state = $entitlementService->getState();
// Check export capability
if (!in_array('export', $state['features'] ?? [], true)) {
?>
<html>
<body>
<h1>Export Not Available</h1>
<p>The export feature is not included in your current plan.</p>
<a href="/?page=dashboard">Return to Dashboard</a>
</body>
</html>
<?php
exit;
}
?>
<html>
<body>
<h1>Export Data</h1>
<form method="POST" action="/api/export.php">
<button type="submit">Export Now</button>
</form>
</body>
</html>
Step 5: API Endpoint
<?php
// api/export.php
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
require_once __DIR__ . '/../lib/EntitlementService.php';
$entitlementService = EntitlementService::getInstance();
$state = $entitlementService->getState();
if (!in_array('export', $state['features'] ?? [], true)) {
http_response_code(403);
echo json_encode(['error' => 'Export capability not available']);
exit;
}
// Perform export
$data = [
'records' => [
['id' => 1, 'name' => 'Record 1'],
['id' => 2, 'name' => 'Record 2']
],
'exported_at' => date('Y-m-d H:i:s')
];
echo json_encode(['success' => true, 'data' => $data]);
?>
Debugging & Error Handling
Plain PHP debugging patterns:
Use Error Logging
<?php
// Enable error logging
ini_set('log_errors', 1);
ini_set('error_log', __DIR__ . '/logs/error.log');
// Log entitlement events
error_log('Authority resolution started');
error_log('State: ' . print_r($state, true));
?>
Create Debug Mode
<?php
if (defined('DEBUG_MODE') && DEBUG_MODE) {
echo '<pre>';
print_r($state);
echo '</pre>';
}
?>
Handle Network Failures
<?php
try {
$state = $entitlementService->resolveAuthority();
} catch (Exception $e) {
error_log('Authority resolution failed: ' . $e->getMessage());
// Use cached state if available
if (!empty($_SESSION['entitlement_state_backup'])) {
$state = $_SESSION['entitlement_state_backup'];
} else {
$state = ['status' => 'DEGRADED'];
}
}
?>
What NOT to Do
Plain PHP anti-patterns:
❌ Single Include Check
// BAD
require_once 'check.php';
if (!$authorized) exit;❌ Hardcoded Keys
// BAD
$entitlement_key = 'XXXX-XXXX-XXXX';❌ No File-Level Protection
// BAD - premium.php has no validation
<?php
// No checks - assumes index.php validated
?>
❌ Session Without Expiry
// BAD
$_SESSION['authorized'] = true; // Never expires
❌ Trusting GET/POST Data
// BAD
if ($_POST['authorized']) { }Conclusion
Plain PHP license validation requires manual implementation at every layer. Key principles:
- Validate in every file — Use include guards everywhere
- Use state arrays, not booleans — Carry context
- Cache with expiry — Session storage with TTL
- Encrypt sensitive data — Never store keys in plain text
- Protect all entry points — Pages, APIs, cron jobs
- Check response codes — Handle specific failure reasons
- Define APP_INIT constant — Prevent direct file access
Build with the assumption that any file can be accessed directly, and ensure each file protects itself.