Security (Imunify360, ModSecurity)

Comprehensive Guide to Securing Web Applications: Best Practices Across All Stacks

Published · Updated 18 min read
Knowledge base article
Contents (11 sections)

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.

TL;DR

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

ControlWhy it mattersHow 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' sessionsSubmit <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-listFilters that remove <script> miss onerror=, javascript: and encoding tricksPaste 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 failsCheck the response headers in browser dev tools
Cookies carrying identity are HttpOnly, Secure, SameSite=Lax or StrictScript cannot read them; they are not sent on cross-site postsLook at the cookie flags in dev tools › Application
Nothing sensitive is stored in localStorageAny XSS reads it in fullSearch the front-end code for localStorage.setItem
Validation happens on the server, whatever the browser didClient-side checks are advice to honest users onlyReplay a request with curl after removing the JavaScript checks

The mistake: printing user input as HTML.

php
<?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
js
// Node/Express equivalent — same rule, one escaper per context
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) =>
  ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 &lt;svg. Then confirm the header: curl -sI https://example.in | grep -i content-security-policy.

2. Backend layer: injection and authorisation

ControlWhy it mattersHow 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 applicationsgrep -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 arrayCommand injection gives the attacker your account's shellSearch 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 invoiceLog 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 itemHidden is not protectedCall 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 themPost 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 versionsTrigger an error with bad input and read the response body

The mistake: building the query from the request.

php
<?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
js
// 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
<?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

ControlWhy it mattersHow to verify
Passwords hashed with bcrypt or Argon2id, never MD5/SHA-1, never reversible [A02, A07]A leaked table must be uselessLook 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 speedTry 20 wrong passwords in a minute; the 21st attempt must be slowed or blocked
Session id regenerated at login and destroyed at logoutStops session fixation and reuseCopy the cookie before login; it must be invalid after
Session cookie flags: Secure, HttpOnly, SameSite; absolute lifetime setLimits theft and replayDev tools › Application › Cookies
Password reset tokens are random (random_bytes), single-use, expire within an hourGuessable or reusable tokens are account takeoverUse a reset link twice; the second must fail
Two-factor authentication offered to users, required for adminsPasswords alone are not enough for privileged accountsLog in to admin without a second factor; it must not be possible

The mistake: comparing plain or weakly hashed passwords.

php
<?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";
}
js
// 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 false

Verify 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

ControlWhy it mattersHow 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 databasesSHOW 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 readRequest 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 luckRestore 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 lessCheck the raw column contents
Database is not reachable from the internet; connections come from localhost or a private networkRemoves a whole attack surfacenmap -p 3306 your-server from outside: closed or filtered

The mistake: one all-powerful database user for everything.

sql
-- 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:

apache
<FilesMatch "(^\.|\.(env|bak|sql|old|orig|swp)$|~$)">
    Require all denied
</FilesMatch>
Options -Indexes

5. Deployment: transport, headers and configuration

ControlWhy it mattersHow to verify
HTTPS everywhere, HTTP redirected, certificate auto-renewing [A02]Plain HTTP exposes cookies and forms to anyone on the pathcurl -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 leaksSame curl -sI, or a header-scanning site
Directory listing off; debug and display_errors off in production [A05]Listings and traces hand attackers a mapRequest 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 patchcomposer outdated, npm audit, the CMS update screen
Deploy from version control, not by editing files on the serverReproducible and reviewable; no forgotten test.phpDiff the server against the repository

The mistake: relying on defaults for headers.

php
<?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" : '';
js
// 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

ControlWhy it mattersHow 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 checksUpload 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 disabledA stored script that cannot execute is just a fileUpload test.php containing <?php echo 1; and request it
Size limits enforced in code and in php.iniA single 2 GB upload can fill the accountUpload something over the limit
Downloads served by a script that checks authorisation, with Content-Disposition: attachmentDirect URLs to private files leak by guessingOpen 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 configRequest ?page=../../wp-config.php

The mistake: trusting the file name.

php
<?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:

apache
<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

ControlWhy it mattersHow 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 attackersnmap -F your-ip from outside
Registrar and DNS accounts have unique passwords and two-factorDomain hijack beats every control on this pageCheck the Domain India account's security settings
DNS records reviewed: no stale A/CNAME records pointing at services you no longer runDangling records get taken overList the zone and resolve every name
Rate limiting at the edge (Cloudflare, or the host's firewall) for login and API pathsBrute force and scraping are slowed before they reach PHPRepeat a request 100 times; expect 429
Email authentication: SPF, DKIM, DMARC on the domainPhishing in your name damages your users and your deliverabilityCheck 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

ControlWhy it mattersHow to verify
Dependency scanning in the pipeline or on a weekly cron (composer audit, npm audit) [A06]Known vulnerabilities are the cheapest attackThe 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 recordFind your own login in the log
Logs are reviewed, not just written; alerts on repeated failuresSilent logs are the same as no logsSomeone can say when they last read them
A staging copy exists for testing updates before productionUpdates are the most common self-inflicted outageThe 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 decisionsThe 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 missThe last report is dated

The mistake: logging nothing until something goes wrong.

php
<?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.

LayerWhat runs on the serverWhat it means for you
Web application firewallModSecurity with the Imunify360 rule set on ApacheCommon 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 protectionImunify360 with real-time scanning and Proactive DefenseNew 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 protectionCSF firewall with cPanel's login protectionRepeated failed logins to cPanel, FTP and mail are blocked at the server
Account isolationCloudLinux CageFSOne compromised account on the server cannot read another account's files or processes
Transport securityAutoSSL with Let's EncryptEvery domain and subdomain pointed at the server gets a free certificate that renews itself
PHP hardeningPer-account PHP version (5.x to 8.5), display_errors off, dangerous functions disabledYou choose the version in MultiPHP Manager; see PHP disabled functions on shared hosting
Server backupsJetBackup on the serverA 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
What the host cannot fix for you

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.

Injection
All queries parameterised; no shell commands with user input; grep confirms it.
Output
Every dynamic value escaped for its context; CSP header present.
Auth
bcrypt/Argon2 hashes; rate-limited login; 2FA for admins; session regenerated at login.
Authorisation
Every record access checks ownership; admin routes check role server-side.
CSRF
Token or SameSite plus Origin check on every state change.
Secrets
Nothing sensitive in the web root or the repository; /.env returns 403.
Transport
HTTPS only; HSTS; certificate renewing; six security headers present.
Uploads
Sniffed by content; random names; stored outside the web root; PHP disabled in upload folders.
Dependencies
composer audit / npm audit clean or triaged this week; CMS and plugins current.
Logging and backups
Security events logged and read; a restore was tested this quarter; incident page exists.

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.

Hosting that handles the server side of this list

ModSecurity with Imunify360, CageFS isolation, CSF firewall, free auto-renewing SSL and per-account PHP versions on every plan.

See cPanel hosting plans

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app