This is a security audit you can run against your own web application in an afternoon, whatever it is written in. Each section is one layer of the stack, opens with a checklist you can tick, shows the one mistake that causes most incidents on that layer with the vulnerable code and the fix side by side, and ends with how to verify the fix from the outside. Controls are mapped to the OWASP Top 10 (2021) categories in brackets. The last section lists what Domain India's shared hosting already does for you, checked on our servers on 6 September 2026, so you can spend your time on the parts only you can fix.
Ten controls stop the large majority of web application breaches. Parameterised queries everywhere (A03). Escape all output for the context it lands in (A03). A CSRF token on every state-changing form (A01). Password hashing with bcrypt or Argon2 and rate-limited logins (A07). Session cookies that are Secure, HttpOnly and SameSite (A07). Every request authorised against the object it touches, not just "logged in" (A01). Secrets in environment or files outside the web root, never in the repository (A05). HTTPS only, with HSTS and a Content-Security-Policy (A02, A05). Uploads validated by content and stored outside the document root (A04). Dependencies updated and logged errors reviewed (A06, A09). Work through the eight checklists below in order.
1. Browser layer: input, output and cross-site scripting
| Control | Why it matters | How to verify |
|---|---|---|
| Every dynamic value is escaped for where it is printed (HTML body, attribute, URL, JavaScript) [A03] | Cross-site scripting runs attacker script in your users' sessions | Submit <svg onload=alert(1)> in every field; it must render as text |
| Rich text goes through an HTML sanitiser with an allow-list, never a deny-list | Filters that remove <script> miss onerror=, javascript: and encoding tricks | Paste the OWASP XSS cheat-sheet payloads into the editor |
A Content-Security-Policy header is set, at least default-src 'self' | Blocks injected inline scripts even when escaping fails | Check the response headers in browser dev tools |
Cookies carrying identity are HttpOnly, Secure, SameSite=Lax or Strict | Script cannot read them; they are not sent on cross-site posts | Look at the cookie flags in dev tools › Application |
Nothing sensitive is stored in localStorage | Any XSS reads it in full | Search the front-end code for localStorage.setItem |
| Validation happens on the server, whatever the browser did | Client-side checks are advice to honest users only | Replay a request with curl after removing the JavaScript checks |
The mistake: printing user input as HTML.
<?php
// Vulnerable: $name comes from the request
$name = $_GET['name'] ?? '<img src=x onerror=alert(document.cookie)>';
// echo "<p>Hello $name</p>"; // runs the attacker's script
// Fixed: escape for the HTML context, always with the charset and quote flags
function e(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
echo '<p>Hello ', e($name), "</p>\n"; // renders the tag as text
echo '<a href="/profile?u=', rawurlencode($name), '">link</a>', "\n"; // URL context: different encoder// Node/Express equivalent — same rule, one escaper per context
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const name = '<img src=x onerror=alert(1)>';
console.log(`<p>Hello ${escapeHtml(name)}</p>`);
console.log(`<a href="/profile?u=${encodeURIComponent(name)}">link</a>`);Verify from outside. Load the page with ?name=%3Csvg%20onload%3Dalert(1)%3E. No alert, and the source shows <svg. Then confirm the header: curl -sI https://example.in | grep -i content-security-policy.
2. Backend layer: injection and authorisation
| Control | Why it matters | How to verify |
|---|---|---|
| Every SQL statement uses bound parameters; no string building, ever [A03] | SQL injection is still the highest-impact bug in PHP and Node applications | grep -rn '"SELECT' src/ and read every hit; each must be a prepared statement |
Shell commands are avoided; where unavoidable, arguments go through escapeshellarg / execFile with an array | Command injection gives the attacker your account's shell | Search for exec(, system(, shell_exec(, child_process.exec( |
| Each request checks that the user owns the record it touches, not only that a user is logged in [A01] | Changing ?invoice=1001 to 1002 must fail, not show another customer's invoice | Log in as two test users and swap ids in every URL and form |
| Admin routes check a role on the server, not a hidden menu item | Hidden is not protected | Call an admin URL as an ordinary user with curl -b cookies |
Every form and JSON endpoint that changes data carries a CSRF token or uses SameSite cookies plus an Origin check [A01] | Otherwise any site the user visits can submit your forms as them | Post to the endpoint from a page on another domain |
| Errors return a generic message; details go to the log [A05] | Stack traces reveal paths, queries and versions | Trigger an error with bad input and read the response body |
The mistake: building the query from the request.
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite::memory:', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
$pdo->exec("CREATE TABLE invoices (id INTEGER PRIMARY KEY, user_id INTEGER, total INTEGER)");
$pdo->exec("INSERT INTO invoices VALUES (1001, 7, 57500), (1002, 8, 115000)");
$currentUserId = 7;
$requestedId = "1002 OR 1=1"; // attacker edits the URL
// Vulnerable: "SELECT * FROM invoices WHERE id = $requestedId" returns everything
// Fixed: bound parameter AND ownership in the same query
$stmt = $pdo->prepare('SELECT id, total FROM invoices WHERE id = :id AND user_id = :uid');
$stmt->execute(['id' => $requestedId, 'uid' => $currentUserId]);
var_dump($stmt->fetch()); // false: not a number, not theirs
$stmt->execute(['id' => 1001, 'uid' => $currentUserId]);
print_r($stmt->fetch()); // their own invoice only// Node: the same two rules with the mysql2 driver (placeholders + ownership).
// Shown for shape; requires the mysql2 package.
async function getInvoice(db, invoiceId, currentUserId) {
const [rows] = await db.execute(
'SELECT id, total FROM invoices WHERE id = ? AND user_id = ?',
[invoiceId, currentUserId],
);
return rows[0] ?? null; // null, not someone else's row
}
module.exports = { getInvoice };The mistake: a form anyone can submit for the user.
<?php
declare(strict_types=1);
session_start();
function csrfToken(): string {
return $_SESSION['csrf'] ??= bin2hex(random_bytes(32));
}
function csrfCheck(?string $submitted): void {
if ($submitted === null || !hash_equals($_SESSION['csrf'] ?? '', $submitted)) {
http_response_code(403);
throw new RuntimeException('Invalid CSRF token');
}
}
// In the form, print it escaped: <input type="hidden" name="_token" value="…csrfToken()…">
$token = csrfToken();
csrfCheck($token); // passes
try { csrfCheck('forged'); } catch (RuntimeException $ex) { echo $ex->getMessage(), PHP_EOL; }Verify from outside. Save one of your own forms as a static HTML file on another host, submit it while logged in, and confirm the server rejects it. Then swap two users' ids in every URL that contains one.
3. Sessions and authentication
| Control | Why it matters | How to verify |
|---|---|---|
| Passwords hashed with bcrypt or Argon2id, never MD5/SHA-1, never reversible [A02, A07] | A leaked table must be useless | Look at a hash in the database: it starts with $2y$ or $argon2id$ |
| Login is rate-limited per account and per IP, with a delay or lockout after repeated failures [A07] | Credential stuffing uses leaked password lists at machine speed | Try 20 wrong passwords in a minute; the 21st attempt must be slowed or blocked |
| Session id regenerated at login and destroyed at logout | Stops session fixation and reuse | Copy the cookie before login; it must be invalid after |
Session cookie flags: Secure, HttpOnly, SameSite; absolute lifetime set | Limits theft and replay | Dev tools › Application › Cookies |
Password reset tokens are random (random_bytes), single-use, expire within an hour | Guessable or reusable tokens are account takeover | Use a reset link twice; the second must fail |
| Two-factor authentication offered to users, required for admins | Passwords alone are not enough for privileged accounts | Log in to admin without a second factor; it must not be possible |
The mistake: comparing plain or weakly hashed passwords.
<?php
declare(strict_types=1);
// Vulnerable: if (md5($password) === $row['password_md5']) — rainbow tables solve this in seconds
// Fixed
$stored = password_hash('S3cure-passphrase', PASSWORD_DEFAULT); // bcrypt today
$ok = password_verify('S3cure-passphrase', $stored);
$bad = password_verify('s3cure-passphrase', $stored);
var_dump($ok, $bad); // true, false
// Rate limit: count failures per account for 15 minutes (any store works; a table, Redis, APCu)
$failures = ['[email protected]' => 5];
$limit = 5;
if (($failures['[email protected]'] ?? 0) >= $limit) {
echo "Too many attempts, try again in 15 minutes\n";
}// Node without extra packages: scrypt from the standard library
const { scryptSync, randomBytes, timingSafeEqual } = require('node:crypto');
function hashPassword(pw) {
const salt = randomBytes(16);
return salt.toString('hex') + ':' + scryptSync(pw, salt, 64).toString('hex');
}
function verifyPassword(pw, stored) {
const [saltHex, hashHex] = stored.split(':');
const candidate = scryptSync(pw, Buffer.from(saltHex, 'hex'), 64);
return timingSafeEqual(candidate, Buffer.from(hashHex, 'hex')); // constant time
}
const h = hashPassword('S3cure-passphrase');
console.log(verifyPassword('S3cure-passphrase', h), verifyPassword('wrong', h)); // true falseVerify from outside. Register a test account, then run a login loop with curl and a wrong password. The response time or status must change before attempt 20. Read the account security best practices and two-factor authentication setup articles for the Domain India account itself.
4. Data layer: database configuration and secrets
| Control | Why it matters | How to verify |
|---|---|---|
| The application's database user has only the grants it needs (usually SELECT, INSERT, UPDATE, DELETE on one database) [A05] | An injected query cannot drop tables or read other databases | SHOW GRANTS FOR CURRENT_USER(); from the application connection |
| Database credentials live outside the web root or in environment variables; nothing in Git [A05] | .env in public_html is one URL away from being read | Request https://example.in/.env and /config.php.bak; both must be 403 or 404 |
| Backups exist, are tested, and are not in a public folder [A08] | Ransomware and bad deploys are recovered from backups, not luck | Restore last week's backup into a test database |
| Personal data fields that are never searched are encrypted at rest (Aadhaar, PAN, card fragments) [A02] | A dumped table exposes less | Check the raw column contents |
| Database is not reachable from the internet; connections come from localhost or a private network | Removes a whole attack surface | nmap -p 3306 your-server from outside: closed or filtered |
The mistake: one all-powerful database user for everything.
-- Vulnerable: GRANT ALL PRIVILEGES ON *.* TO 'app'@'%'
-- Fixed: least privilege, local only
CREATE USER 'cpaneluser_app'@'localhost' IDENTIFIED BY 'a-long-random-password';
GRANT SELECT, INSERT, UPDATE, DELETE ON cpaneluser_shop.* TO 'cpaneluser_app'@'localhost';
FLUSH PRIVILEGES;On cPanel the same thing is done without SQL: create the database and the user in MySQL Databases, then in Add User to Database untick everything except SELECT, INSERT, UPDATE, DELETE. Keep a second user with full rights for migrations only.
Verify from outside. From a browser, request /.env, /.git/config, /config.php~, /wp-config.php.bak and /backup.sql on your domain. Every one must be denied. On Apache, a .htaccess in the web root does it:
<FilesMatch "(^\.|\.(env|bak|sql|old|orig|swp)$|~$)">
Require all denied
</FilesMatch>
Options -Indexes5. Deployment: transport, headers and configuration
| Control | Why it matters | How to verify | |
|---|---|---|---|
| HTTPS everywhere, HTTP redirected, certificate auto-renewing [A02] | Plain HTTP exposes cookies and forms to anyone on the path | curl -I http://example.in returns 301 to https; certificate expiry more than 14 days away | |
Strict-Transport-Security set once HTTPS is stable [A02] | Browsers refuse to downgrade even on a first visit | `curl -sI https://example.in \ | grep -i strict` |
X-Content-Type-Options: nosniff, X-Frame-Options: DENY or CSP frame-ancestors, Referrer-Policy set [A05] | Cheap headers that close MIME sniffing, clickjacking and referrer leaks | Same curl -sI, or a header-scanning site | |
Directory listing off; debug and display_errors off in production [A05] | Listings and traces hand attackers a map | Request a folder without an index file; expect 403 | |
| Framework, CMS, plugins and libraries on supported versions, updated on a schedule [A06] | Most mass compromises exploit a patched bug months after the patch | composer outdated, npm audit, the CMS update screen | |
| Deploy from version control, not by editing files on the server | Reproducible and reviewable; no forgotten test.php | Diff the server against the repository |
The mistake: relying on defaults for headers.
<?php
declare(strict_types=1);
// Send once, early, from a bootstrap file every page includes
function securityHeaders(): void {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: strict-origin-when-cross-origin');
header("Content-Security-Policy: default-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'");
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
}
securityHeaders();
echo PHP_SAPI === 'cli' ? "headers queued (CLI cannot send them)\n" : '';// Node: same headers from a tiny middleware, no packages needed
const http = require('node:http');
const HEADERS = {
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Content-Security-Policy': "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
};
const server = http.createServer((req, res) => {
for (const [k, v] of Object.entries(HEADERS)) res.setHeader(k, v);
res.end('ok');
});
server.listen(0, () => {
http.get({ port: server.address().port }, (res) => {
console.log(res.headers['content-security-policy']);
server.close();
});
});On Apache shared hosting the same headers can go in .htaccess with Header always set ..., which covers static files too. Start CSP in Content-Security-Policy-Report-Only for a week and read the reports before enforcing it; a strict policy will break inline scripts and third-party widgets you forgot about.
Verify from outside. curl -sI https://example.in and read the six headers. Then check the certificate: echo | openssl s_client -connect example.in:443 2>/dev/null | openssl x509 -noout -dates.
6. Files and uploads
| Control | Why it matters | How to verify |
|---|---|---|
Upload type decided by reading the bytes (finfo, magic numbers), not by extension or the browser's Content-Type [A04] | shell.php renamed photo.jpg passes extension checks | Upload a text file renamed .png |
| Uploaded files get a new random name and land outside the document root, or in a folder where PHP execution is disabled | A stored script that cannot execute is just a file | Upload test.php containing <?php echo 1; and request it |
Size limits enforced in code and in php.ini | A single 2 GB upload can fill the account | Upload something over the limit |
Downloads served by a script that checks authorisation, with Content-Disposition: attachment | Direct URLs to private files leak by guessing | Open a file URL in a private window |
No user input reaches include, require, fopen or file_get_contents paths unfiltered [A01] | Path traversal reads /etc/passwd or your config | Request ?page=../../wp-config.php |
The mistake: trusting the file name.
<?php
declare(strict_types=1);
// Vulnerable: move_uploaded_file($_FILES['f']['tmp_name'], 'uploads/' . $_FILES['f']['name']);
// Fixed: sniff bytes, allow-list, random name, outside the web root
function acceptImage(string $tmpPath, string $privateDir): string {
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($tmpPath);
$ext = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'][$mime] ?? null;
if ($ext === null) throw new RuntimeException("Rejected type $mime");
$name = bin2hex(random_bytes(16)) . ".$ext";
if (!rename($tmpPath, "$privateDir/$name")) throw new RuntimeException('Store failed');
return $name;
}
$fake = tempnam(sys_get_temp_dir(), 'x'); file_put_contents($fake, '<?php echo 1;');
try { acceptImage($fake, sys_get_temp_dir()); } catch (RuntimeException $e) { echo $e->getMessage(), PHP_EOL; }For the folder that must stay web-reachable (for example uploads/), add a .htaccess that turns PHP off:
<FilesMatch "\.(php|phtml|phar)$">
Require all denied
</FilesMatch>Verify from outside. Upload a file named test.php through your own form and request it. You want a download prompt, a 403, or a rejected upload, never the number 1.
7. Network and DNS
| Control | Why it matters | How to verify |
|---|---|---|
| Only 80 and 443 are reachable on the application host; admin panels, databases and SSH are firewalled or behind a VPN [A05] | Every open port is a login form for attackers | nmap -F your-ip from outside |
| Registrar and DNS accounts have unique passwords and two-factor | Domain hijack beats every control on this page | Check the Domain India account's security settings |
| DNS records reviewed: no stale A/CNAME records pointing at services you no longer run | Dangling records get taken over | List the zone and resolve every name |
| Rate limiting at the edge (Cloudflare, or the host's firewall) for login and API paths | Brute force and scraping are slowed before they reach PHP | Repeat a request 100 times; expect 429 |
| Email authentication: SPF, DKIM, DMARC on the domain | Phishing in your name damages your users and your deliverability | Check the TXT records; the Cloudflare setup guide covers DNS and proxying |
Verify from outside. nmap -F example.in should show 80 and 443 only for a VPS you run yourself. On shared hosting the server's port set is managed for you (section 9).
8. Testing, logging and response
| Control | Why it matters | How to verify |
|---|---|---|
Dependency scanning in the pipeline or on a weekly cron (composer audit, npm audit) [A06] | Known vulnerabilities are the cheapest attack | The report is less than a week old |
| Errors, logins, failed logins, password changes and admin actions are logged with time, user and IP [A09] | You cannot investigate what you did not record | Find your own login in the log |
| Logs are reviewed, not just written; alerts on repeated failures | Silent logs are the same as no logs | Someone can say when they last read them |
| A staging copy exists for testing updates before production | Updates are the most common self-inflicted outage | The staging URL works today |
| An incident plan names who to call, how to take the site offline, and where the last clean backup is [A09] | Decisions under pressure are bad decisions | The plan fits on one page and everyone has it |
| An external scan runs periodically (OWASP ZAP baseline, a commercial scanner, or a manual pass through these checklists) | Finds what the developer's habits miss | The last report is dated |
The mistake: logging nothing until something goes wrong.
<?php
declare(strict_types=1);
function securityLog(string $event, array $ctx = []): void {
$line = json_encode([
'ts' => (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format(DATE_ATOM),
'event' => $event,
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli',
'ua' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 120),
] + $ctx, JSON_UNESCAPED_SLASHES);
error_log($line); // lands in the account's PHP error log
}
securityLog('login.failed', ['email' => '[email protected]', 'reason' => 'bad_password']);
securityLog('password.changed', ['user_id' => 42]);
echo "two events logged\n";Log the event and the identifier, never the password, the token or the full card number.
9. What Domain India shared hosting already covers
Checked on our cPanel servers on 6 September 2026. These controls run for every account without configuration; they reduce the blast radius, they do not replace the application-level fixes above.
| Layer | What runs on the server | What it means for you |
|---|---|---|
| Web application firewall | ModSecurity with the Imunify360 rule set on Apache | Common injection, XSS and scanner traffic is blocked before it reaches your code; a legitimate request can occasionally trip a rule, in which case support can whitelist it |
| Malware and intrusion protection | Imunify360 with real-time scanning and Proactive Defense | New and changed files are scanned as they are written and infected files are cleaned automatically; known-malicious PHP behaviour is stopped at run time |
| Server firewall and brute-force protection | CSF firewall with cPanel's login protection | Repeated failed logins to cPanel, FTP and mail are blocked at the server |
| Account isolation | CloudLinux CageFS | One compromised account on the server cannot read another account's files or processes |
| Transport security | AutoSSL with Let's Encrypt | Every domain and subdomain pointed at the server gets a free certificate that renews itself |
| PHP hardening | Per-account PHP version (5.x to 8.5), display_errors off, dangerous functions disabled | You choose the version in MultiPHP Manager; see PHP disabled functions on shared hosting |
| Server backups | JetBackup on the server | A safety net for the server, not a substitute for your own scheduled backups of the database and files; automated backups with cron and rclone shows how |
Nothing on the server can tell a SQL query built from user input apart from a normal one, recognise that invoice 1002 belongs to someone else, or know that your admin password is admin123. Sections 1 to 8 are yours; section 9 is ours. Most compromises we clean up on shared hosting come through outdated CMS plugins, reused passwords and uploads that were never validated.
10. The one-page audit
Print this and tick it quarterly.
grep confirms it./.env returns 403.composer audit / npm audit clean or triaged this week; CMS and plugins current.Frequently asked questions
I use a framework (Laravel, Django, Express, Next.js). Does this still apply?
Yes. Frameworks give you parameterised queries, escaping and CSRF tokens by default, and every one of them lets you bypass those defaults with raw queries, {!! !!}, dangerouslySetInnerHTML or a disabled middleware. Audit the places where you opted out.
Which is more important, a WAF or fixing the code?
Fixing the code. A WAF such as ModSecurity blocks known attack patterns and buys time; it does not know your authorisation rules and can be bypassed by encoding. Treat the host-side WAF as a seatbelt, not as the brakes.
How do I test for SQL injection safely?
In a copy of the site, add a single quote to every parameter and watch for errors or changed results, then run the OWASP ZAP baseline scan against the copy. Never test against production or against a site you do not own.
Do I need a Content-Security-Policy on a small site?
Yes, and small sites are the easiest place to add one because there are few scripts. Start with Content-Security-Policy-Report-Only, fix what it reports, then enforce it.
Where should secrets live on shared hosting?
In a file above public_html that your code requires, with permissions 600, or in SetEnv lines in .htaccess. Never in a file the web server can serve and never committed to Git.
Is HTTP Basic auth on the admin folder enough?
It is a good extra layer over HTTPS, not a replacement for application login, rate limiting and 2FA. Combine it with the real authentication, do not substitute it.
My site was hacked. What first?
Take it offline or put up a maintenance page, change every password (hosting, database, CMS, FTP, email), restore from a backup taken before the compromise, then update everything and find the entry point before going live. Open a ticket so we can check the account from the server side.
How often should I run this audit?
Fully once a quarter and after any major change; the dependency check weekly; the backup restore test at least twice a year.
ModSecurity with Imunify360, CageFS isolation, CSF firewall, free auto-renewing SSL and per-account PHP versions on every plan.
See cPanel hosting plans