1. The Ticket That Arrived at 04:12
A cycling retailer I work with pushed a routine content release on a Tuesday evening. At 04:12 the following morning their fraud team's automated report flagged nineteen declined authorisations from cards that had never been used on the site before. By 07:00 someone had noticed that the checkout page was loading a script from a domain nobody recognised, served from a bucket registered eleven days earlier.
The interesting part was not the script. The interesting part was the chain that let it get there. An agency contractor who had left in March still had an admin account. That account had no second factor because it predated the policy. The password had been reused on a forum that leaked in 2023. The account had permission to edit CMS blocks, one of which was rendered on every page including checkout. Nothing was exploited. Nothing was even hacked, in the sense that word usually implies. Somebody logged in.
The post-incident review produced a list of thirty-one items. Twenty-nine of them were things the team already knew they should be doing. That is the pattern I see almost every time. Ecommerce security failures are rarely a clever attack against a well-run system; they are an ordinary attack against a system where six ordinary things had quietly stopped being true.
This article is the operational checklist I use for that. Not the network layer — that lives in the infrastructure hardening checklist, which covers SSH, firewalls, file permissions and database access on a Magento-style stack. Not the response headers either; those are covered in depth in the HTTP security headers guide. This is the layer above both: the accounts, the patching, the dependencies, the backups, the logs, and the human process that decides whether any of the technical controls stay true six months after you configure them.
It is written to be worked through. Take a morning, go down it, and write "yes" or "no" next to each item honestly. The honest "no" is the valuable output.
2. Why Most Security Checklists Are Useless
I have read a lot of ecommerce security checklists. Most of them fail for one of three reasons.
The first is that they list controls rather than states. "Enable two-factor authentication" is a control. "Every account that can reach the admin panel has a second factor, and there is a query I can run that proves it" is a state. Controls get enabled once and then drift; states can be checked. Whenever I write a checklist item now I try to phrase it as something I could verify in under five minutes, because an item I cannot verify is an item I will assume is fine.
The second is that they treat every item as equally important. A checklist that puts "remove the server version banner" next to "make sure your backups restore" has told you nothing about where to spend Tuesday. In practice a small number of items account for nearly all real-world compromise: credentials, unpatched software, third-party code, and over-broad access. Everything else matters, but it matters after those.
The third is that nobody owns the checklist. It gets produced during an audit, lands in a shared drive, and is never opened again. The checklists that work have a named owner per section and a recurrence — quarterly, or tied to a release cycle — and the recurrence is the part that does the work.
So the structure below is deliberately ordered. If you get through the first six sections and stop, you will have addressed most of your actual risk. The later sections are the ones that turn a good position into a durable one.
3. Draw the Map Before You Harden Anything
You cannot secure a system you cannot enumerate, and almost nobody can enumerate theirs on the first attempt.
The exercise takes an afternoon and produces a document that will be wrong within a month, which is fine — the point is that it is wrong in known ways rather than unknown ones. Four lists.
Every system that can reach production data. Not just the web servers. The CI runner that deploys. The analytics warehouse that syncs orders nightly. The ERP connector. The developer laptop with a database dump from the incident in January. The staging environment restored from a production snapshot two years ago and never touched since.
Every human and machine identity that can log in to any of them. Include service accounts, API keys, and the shared login that the warehouse uses. Include the agency. Include the person who set the whole thing up in 2019 and now works elsewhere.
Every piece of code you did not write that runs in production. Extensions, apps, npm packages, Composer packages, tag manager containers, the chat widget, the review widget, the pixel your marketing agency added last quarter without telling anybody.
Every place customer data leaves your control. Email service, error tracker, session recording tool, support desk, fulfilment partner, the spreadsheet someone exports monthly.
That fourth list is the one that surprises people. On the homeware account it ran to fourteen entries, four of which nobody could name a current owner for. Two of those four were still receiving data.
A cheap first pass
You can get a rough version of the third list mechanically. On a Magento 2 estate:
# Everything installed, including things nobody remembers installing
bin/magento module:status --enabled | sort
# Composer packages that are not first-party, with their installed versions
composer show --direct --format=json \
| python3 -c 'import json,sys; [print(p["name"], p["version"]) for p in json.load(sys.stdin)["installed"] if not p["name"].startswith("magento/")]'
# Anything that has been patched in place — a classic source of upgrade pain
ls -la vendor/*/*/ 2>/dev/null | grep -i patch
composer show -i 2>/dev/null | wc -l
And in the browser, on the live site, for the client-side half:
// Paste into the console on a product page and again on checkout.
// The two lists are usually different, and the difference is the interesting part.
const here = location.origin;
const rows = performance.getEntriesByType('resource')
.filter(e => e.initiatorType === 'script' || e.initiatorType === 'xmlhttprequest')
.filter(e => !e.name.startsWith(here))
.map(e => ({ host: new URL(e.name).host, type: e.initiatorType }));
// Collapse to hosts so the output is readable rather than exhaustive
const byHost = {};
for (const r of rows) byHost[r.host] = (byHost[r.host] || 0) + 1;
console.table(byHost);
Run that on a real customer journey rather than a cold page load — some vendors only inject their second-stage script after a consent event or a cart action, and a single page view will not see them.
4. Access Control, Which Is Where the Incidents Come From
If you do nothing else from this article, do this section. In my experience the overwhelming majority of ecommerce compromises begin with a valid credential rather than a memory-safety bug.
Individual accounts, always. No shared logins, including for the agency, including for the warehouse, including for the seasonal temp. A shared account means you cannot attribute an action, cannot revoke access for one person without disrupting everyone, and cannot answer the first question a forensic investigator asks. If the objection is licensing cost, the licensing cost is smaller than the incident.
A second factor on everything that reaches production. Admin panel, hosting console, DNS registrar, source control, CI, payment provider dashboard, email service. The DNS registrar is the one people forget, and it is arguably the worst one to lose — an attacker with your DNS can reissue certificates and receive your mail.
Prefer hardware keys or app-based TOTP over SMS. SIM swap attacks against ecommerce staff are not theoretical; I have seen one succeed against a finance director whose mobile number was on the company's own contact page. If SMS is the only option a vendor supports, that is a reason to raise it with the vendor.
Least privilege that is actually enforced. Most platforms have role systems that nobody uses because the default admin role is easier. The content team does not need to change payment configuration. The support team does not need to install modules. Building three or four real roles takes an afternoon and permanently reduces the blast radius of a phished password.
A joiners-movers-leavers process that has a technical step. Not "HR emails IT". A checklist item, in the offboarding document, that says which systems to revoke, with a named owner. Then a quarterly reconciliation: export the account list from every system and compare it against the current staff list. The March contractor in the opening story would have been caught by a single reconciliation run in April.
Making the reconciliation cheap
The reason nobody reconciles accounts is that it takes an hour of clicking. Automate the export and it takes five minutes.
-- Magento 2: admin users, last login, and 2FA state.
-- Run this monthly and diff it against last month.
SELECT
u.username,
u.email,
u.is_active,
u.created,
u.logdate AS last_login,
DATEDIFF(NOW(), u.logdate) AS days_since_login,
(SELECT COUNT(*) FROM tfa_user_config c WHERE c.user_id = u.user_id) AS tfa_rows
FROM admin_user u
ORDER BY days_since_login DESC;
Anything with a null last login, or more than sixty days idle, or zero 2FA rows, is a question. Not necessarily a problem — the release account may legitimately be idle — but a question with an answer that someone writes down.
The variation I like better, for teams that will not run SQL monthly, is a scheduled job that posts the same output into a Slack channel on the first of the month. Making the data arrive unprompted is the difference between a process that happens and one that is documented.
5. The Admin Panel Is a Separate Application
Treat your admin as if it were a different product with different users, because it is.
Move it off the default path. On Magento, changing /admin to something unguessable is not security — the URL will leak eventually, through a referrer header or a bookmark or a screenshot in a support ticket. What it does do is remove you from the vast background noise of automated login attempts, which makes your logs readable and your rate limiting meaningful. That is worth having on its own.
Restrict by network where you can. If your staff work from known locations or through a VPN, an allowlist at the edge is the single strongest control available and takes ten minutes. If they do not, a device-based or identity-aware proxy achieves the same thing with more setup. The version I would avoid is an allowlist that gets a new exception every fortnight until it covers half the internet.
Rate limit and lock out. Failed login attempts should slow down and eventually stop. Then alert on lockouts, because a lockout on an account belonging to someone on holiday is a signal.
Give it a stricter policy than the storefront. Shorter session lifetime, no third-party scripts at all, a tighter Content Security Policy. The admin does not need your analytics tag. This is one of the places where the headers work and the access work meet, and the reasoning is set out in the admin-panel section of the headers guide.
A caveat I would offer about session lifetime, because I got this wrong once. I set an admin session to fifteen minutes on a client with a large customer service team, and within a week they had all started leaving a tab open with a browser extension clicking a button every ten minutes. The control was technically in place and practically inverted. Thirty minutes with a re-authentication prompt for sensitive actions would have been better than fifteen minutes with a workaround.
6. Patching, and the Backlog Nobody Owns
Unpatched software is the second reliable route in. The problem is almost never that a team disagrees about patching; it is that nobody owns the calendar.
Know your sources. For Magento, that is the Adobe security bulletins and the quarterly release cycle. For Shopify, the platform patches itself but your apps and your theme do not. For the operating system, your distribution's security feed. For your language runtime, its end-of-life schedule — running an unsupported PHP or Node version means no security fixes at all, which is a different and worse category of problem than being a month behind.
Set a target and measure against it. Critical patches within a week, high within a month, everything else within a quarter is a reasonable starting position and matches what most compliance regimes expect. What matters more than the exact numbers is that they exist and that someone reports against them.
Make the boring path the easy path. If applying a security release requires a two-day regression test by hand, you will not do it monthly. The investment that pays here is a smoke test suite covering the twenty journeys that matter — add to cart, checkout, login, search, admin order edit — that runs in under fifteen minutes. That is what converts patching from a project into a routine.
# .github/workflows/security-watch.yml
# Runs nightly. Fails loudly rather than opening a PR nobody reviews.
name: security-watch
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Composer advisories
run: composer audit --format=plain --abandoned=report
- name: JS advisories (production dependencies only)
# Dev-only advisories are noise here; they cannot reach a customer.
run: npm audit --omit=dev --audit-level=high
- name: Base image CVEs
uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/example/storefront:latest
severity: HIGH,CRITICAL
exit-code: '1'
ignore-unfixed: true # a CVE with no fix available is a decision, not a build failure
The ignore-unfixed flag is the one I would draw attention to. Without it, teams learn within a fortnight that the security job is always red and start ignoring it, which is worse than not having it. A scanner that cries wolf is a scanner you have disabled.
The dependency you cannot patch
Every estate has one. An extension whose vendor stopped responding in 2022, sitting on a version of a library with a known deserialisation bug. The options are honest and limited: fork and patch it yourself, replace it, or accept the risk with a documented compensating control and a date to revisit.
What I would not do is leave it undocumented. On one project we found a payment-adjacent module in exactly this state, and the decision to keep it was defensible; the problem was that the decision had been made verbally eighteen months earlier by someone who had since left, so it had to be made again from scratch during an audit. A three-line entry in a risk register would have saved a fortnight.
7. Third-Party Code Is Your Code
Every extension, app and script runs with your privileges, on your domain, in front of your customers. The legal boundary and the technical boundary are not in the same place.
The questions I ask before anything new goes in:
What does it actually need? A review widget needs to render on product pages. It does not need to run on checkout. Most tag and app installs default to "everywhere" and almost none of them need to be.
Who has admin on it? A Shopify app that requests write access to orders and customers is an account with those permissions. If the vendor is breached, so are you, and the merchant's app list is functionally an access list.
Is it maintained? Last release date, open issue count, whether the vendor publishes security contacts. A package whose last commit was three years ago is not stable; it is unmaintained, and those are different words.
Can I pin it? Scripts loaded from a vendor CDN with no version in the URL can change under you at any time. That is the polyfill.io shape of problem, and it is exactly what subresource integrity exists to catch — with the honest caveat, covered there, that SRI and tag managers do not get on.
For the checkout page specifically, the bar should be much higher than everywhere else, and PCI DSS v4.0 now requires you to be able to justify and inventory every script that runs on it. The mechanics of that requirement, and the scope questions underneath it, are in the PCI DSS blueprint. The operational version, which is what belongs on this checklist, is simpler: maintain a file in your repository listing every third-party script on the payment page with an owner and a reason, and fail the build if the rendered page contains a script host that is not in it.
#!/usr/bin/env python3
"""checkout-scripts.py — compare live checkout script hosts to the allowlist.
Run in CI after deploy, and nightly. Exit 1 on drift."""
import re
import sys
from urllib.parse import urlparse
from urllib.request import urlopen
URL = "https://shop.example.com/checkout"
ALLOWED = { # host -> owner, reason
"shop.example.com": ("platform", "first-party bundle"),
"js.stripe.com": ("payments", "hosted fields"),
"cdn.example-cdn.net": ("platform", "static assets"),
}
html = urlopen(URL, timeout=20).read().decode("utf-8", "replace")
hosts = {urlparse(src).netloc or urlparse(URL).netloc
for src in re.findall(r'<script[^>]+src="([^"]+)"', html)}
unexpected = sorted(hosts - set(ALLOWED))
if unexpected:
print("UNEXPECTED SCRIPT HOSTS ON CHECKOUT:", file=sys.stderr)
for h in unexpected:
print(f" {h}", file=sys.stderr)
sys.exit(1)
print(f"checkout ok — {len(hosts)} known hosts")
That catches the markup case only. Scripts injected at runtime by other scripts need a browser to see, which means running the same idea in a headless browser against a real page load — more setup, and worth it for a payment page.
8. Secrets, and the Ones Already in Git
Two separate problems: keeping new secrets out, and dealing with the ones that are already in.
For new secrets, the rules are unremarkable. Nothing sensitive in the repository. Configuration comes from the environment or a secrets manager. Local development uses its own credentials that have no production access. CI gets scoped, short-lived tokens rather than a long-lived key with broad permissions.
For existing secrets, the uncomfortable truth is that if a credential has ever been committed, it is compromised, even if the commit was reverted and the repository is private. Git history is durable, backups exist, clones exist, and a former contractor's laptop exists. Rotate it. A scan takes minutes:
# Full-history scan. On a large repo this takes a while — let it run.
docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest \
detect --source=/repo --report-format=json --report-path=/repo/gitleaks.json
# Then, for a Magento estate specifically, the file that matters most
grep -n 'crypt\|key' app/etc/env.php | head
The Magento encryption key deserves its own sentence. It encrypts payment configuration and other sensitive values in the database. If it leaks, the database dump that leaked with it is far more interesting to an attacker. If you find it in a repository, in a Slack message, or in a wiki page, rotating it is a real piece of work — it re-encrypts values across the install — but it is work you have to do, and the mechanics belong in the same conversation as the file permission rules covered in the server hardening checklist.
A pattern I have adopted and would recommend: put the secret scan in a pre-commit hook as well as in CI. CI catches it after it is in history, which means you are already rotating. The hook catches it before, which means you are not.
9. Backups That Have Actually Been Restored
Ransomware turned backups from an availability control into a security control, and most ecommerce backup arrangements were designed for the availability case only.
Three properties, in order of how often I find them missing.
Tested. An untested backup is a hypothesis. Restore into a scratch environment on a schedule — quarterly at minimum — and time it. The number you want is not "we have backups" but "we restored the 14 March snapshot into a clean environment in 47 minutes and the order count matched". Write the number down; it is also your honest recovery time objective, which is usually longer than the one in the contract.
Offline or immutable. If the credentials that run your application can delete your backups, an attacker with those credentials can delete your backups, and several ransomware operators do exactly that before encrypting anything. Object storage with an immutability policy, or a copy in a separate account that production has no path to, is the control. This is the item I most often find missing on otherwise well-run estates.
Retained long enough to cover slow discovery. The median time between compromise and detection is measured in weeks, not hours. Seven days of retention means that by the time you find out, every backup you hold already contains the attacker's web shell. Keep a longer tail — monthly snapshots for a year is cheap at ecommerce data volumes.
Also, and this is the one that bites during an actual restore: back up the things that are not the database. The media directory. The generated configuration. The env.php. The TLS certificates and the DNS zone file. On one recovery I sat through, the database came back in twenty minutes and the product imagery took two days because it lived on a volume that the backup job had never included.
10. Logging: Collect Less, Read More
Most merchants have logs. Very few have logs anyone looks at, and a log nobody reads is a storage bill with a compliance story attached.
The events that are worth alerting on, in roughly the order I would add them:
Admin authentication. Successes, failures, lockouts, and especially successes from a country or ASN you have never seen before. This single alert would have caught the opening incident eleven days early.
Privilege and configuration change. A new admin user, a role change, a change to payment configuration, a change to a CMS block that renders on checkout. These are rare, high-consequence events; alerting on all of them produces a handful of notifications a month.
New or changed files in the application directory. On a deployed-artefact setup, nothing in the application tree should change between deployments. If it does, that is either a bad deployment process or a web shell, and both are worth knowing about.
Outbound connections to hosts you did not expect. Data exfiltration and command-and-control both look like this. Harder to set up and disproportionately valuable.
# A poor man's file integrity monitor for a Magento tree.
# Baseline after deploy; compare on a timer.
BASE=/var/backups/fim
APP=/var/www/shop
baseline() {
find "$APP" -type f \
\( -name '*.php' -o -name '*.phtml' -o -name '*.js' \) \
-not -path "*/var/*" -not -path "*/generated/*" -not -path "*/pub/static/*" \
-exec sha256sum {} + | sort -k2 > "$BASE/current.sha256"
}
case "${1:-check}" in
baseline) mkdir -p "$BASE"; baseline; mv "$BASE/current.sha256" "$BASE/known.sha256" ;;
check)
baseline
if ! diff -u "$BASE/known.sha256" "$BASE/current.sha256" > "$BASE/drift.txt"; then
# Only report added/changed files; deletions during cleanup are noisy.
grep '^+' "$BASE/drift.txt" | grep -v '^+++' | mail -s "FIM drift on $(hostname)" [email protected]
exit 1
fi
echo "no drift" ;;
esac
That is deliberately crude. Proper file integrity monitoring tools exist and are better. But a crude one that runs beats a good one that is on the roadmap, and I have found real problems with almost exactly this script.
One warning from experience: the first week of any file-integrity or anomaly alerting is unbearable. You will discover that your deployment process touches files you did not know about, that a cron job rewrites a config every night, and that a caching layer writes into a directory you assumed was static. Push through that week and tune. Teams that give up in the noise phase end up with the tool installed and the alerts muted, which is the worst of both.
11. The Storefront Attack Surface
The customer-facing side has its own set of items, and they are mostly about not trusting input and not shipping code you cannot account for.
Bot and enumeration defence. Credential stuffing against customer accounts, card testing against your payment endpoint, and inventory scraping all look like ordinary traffic in aggregate and obvious abuse per-session. Rate limit login, password reset, gift card balance checks, and the payment endpoint separately from general traffic — the payment one especially, because card testing at scale will get you fined by your acquirer long before it costs you a fraud loss.
Do not leak account existence. "No account with that email" on a login form is a free enumeration oracle. Same message, same timing, for both cases.
Uploads. Anywhere a customer can upload a file — returns portals, custom product artwork, support attachments — validate type by content rather than extension, store outside the web root, and serve through a handler. The classic ecommerce breach of this shape is a personalisation feature that accepts an image and cheerfully stores logo.php.
Response headers. A Content Security Policy is the strongest single control against the injected-script class of attack, and it is also the one most likely to be deployed in report-only mode and left there for a year. Getting it enforcing is a project of its own; the staged approach I use is set out in the headers article, and strict transport security in the HSTS guide.
Know what your CDN is doing. Caching rules that vary by cookie, or do not, are a security question as much as a performance one. The classic failure is a personalised block cached at the edge without varying on the session, serving one customer's name and order history to everyone. I have seen that happen twice, both times after a well-intentioned performance change.
12. People, Process, and the Agency With Admin Access
The technical controls above are the easy part. What decides whether they are still true next year is process, and process on an ecommerce team usually means three or four specific things.
Change control that is proportionate. Not a change advisory board for a copy tweak. But a payment configuration change, a new admin user, or a new script on checkout should require a second pair of eyes. The lightweight version — those changes go through a pull request even when the platform allows them in the UI — works well because it puts a review and an audit trail on exactly the actions that matter.
Vendor access with an expiry. Agencies, contractors and support engineers should get time-limited accounts. Most platforms do not support expiry natively, which means it becomes a calendar entry, which means it needs an owner. The quarterly reconciliation described earlier is the backstop when that fails, and it will fail.
Phishing awareness that is specific. Generic training is close to useless. Training that says "our finance team will never be asked by email to change bank details for a supplier, and here is what the last three attempts looked like" is not. Ecommerce teams get targeted with plausible, industry-specific lures: fake chargeback notices, fake marketplace suspension warnings, fake courier claims.
A written division of responsibility with every provider. Who patches the operating system on your managed hosting — you or them? Who is responsible for the WAF ruleset? Who monitors the payment provider's status page? Most merchants have contracts and not answers, and the moment to find out is not during an incident.
13. Incident Readiness, Which Is Mostly Paperwork Done Early
Every merchant I have worked with who handled an incident well had done the same small amount of preparation, and it was never a large document.
One page. Who declares an incident. Who has authority to take the site offline — and, importantly, whether they can be reached at 04:00. Who contacts the acquirer, and within what window, because your merchant agreement almost certainly specifies one measured in hours. Who talks to customers. Who engages the forensic investigator, and from which approved list. Where the out-of-hours numbers are, including the ones for your hosting provider and your payment provider, and where that page lives if your systems are the thing that is down.
That last detail is not a joke. I have watched a team try to find their escalation contacts in a wiki hosted on the infrastructure they had just isolated.
Then the technical readiness items, which take a day and save a week:
Know how to preserve evidence. The instinct during an incident is to clean up: delete the web shell, reset the password, redeploy. That destroys the evidence needed to find out what else was touched. Snapshot first, then remediate. Have the snapshot command written down, because nobody composes it correctly under pressure.
Know how to revoke everything quickly. A documented procedure to invalidate all admin sessions, rotate all API keys, and force a customer password reset. Practise the session invalidation at least once — on several platforms it is less obvious than it should be.
Have a holding page ready. A static page you can put in front of the site in minutes, that does not depend on the application. If you have to write one during an incident you will get the DNS wrong.
Run a tabletop once a year. Ninety minutes, a made-up scenario, the actual people in the room. It always finds two or three gaps, and it converts the plan from a document into something people have rehearsed.
14. A Worked Example: Ninety Days on a Homeware Retailer
Back to the client from the opening. Around 4,000 orders a month, Magento 2.4 on managed hosting, an in-house team of three developers and an agency doing front-end work. Here is what the ninety days after the incident actually looked like, including the parts that went badly.
Week one. Containment and the obvious. All admin sessions invalidated, all admin passwords reset, 2FA made mandatory with no grandfathering. Fourteen admin accounts disabled, of which two turned out to be in active use by people who had not been on the account list anyone gave us — a warehouse supervisor and someone at the fulfilment partner. That was an unwelcome surprise that produced two angry phone calls and, on reflection, an accurate picture of who actually had access. The malicious CMS block was removed and a snapshot taken first, which mattered later because it let the investigator confirm that the injection had only been live for nine hours.
Weeks two and three. Inventory. The four lists from earlier in this article. The third-party script list on checkout came to eleven entries; after asking each owner what would break if it were removed, six survived. Page weight on checkout dropped by 214KB and the largest contentful paint improved by about 400ms on a throttled 4G profile, which is a performance result that arrived entirely as a side effect of a security exercise. That is a common enough pattern that I now use it to sell the exercise.
Weeks four to six. Patching. The estate was five security releases behind. This is where the plan slipped, badly. Two extensions broke on the upgrade, one of them the shipping rate calculator, which is not a module you can be casual about. We lost eleven days to it. In hindsight I should have run the upgrade against a production-data staging environment in week one, in parallel with containment, rather than starting it after the inventory. The dependency was not real; I sequenced them for tidiness and it cost a fortnight.
Weeks seven and eight. Logging and alerting. Admin authentication events, configuration changes and the file integrity check wired into their existing monitoring. The first six days produced 61 alerts, of which two were interesting: a deployment process that rewrote a template file outside of a release, and a scheduled export job that was authenticating as a named individual who had left in January. Both were fixed. After tuning, the steady state settled at roughly three alerts a week.
Weeks nine to twelve. Backups and rehearsal. Immutable copies into a separate account, retention extended from 14 days to 12 months on monthly snapshots, and a restore test. The restore test is the number I would put in front of a board: the database restored in 34 minutes, and the media directory took 31 hours, because it had never been part of the backup and had to be rebuilt from a mixture of the CDN cache and a two-year-old archive. Eleven hundred product images were unrecoverable and had to be re-shot. Their stated recovery time objective before the test was four hours.
The residual honest position at the end of ninety days: two extensions still unpatched with documented compensating controls and a review date, no enforcing Content Security Policy yet — it was still report-only, and getting it enforcing took another two months — and a WAF ruleset nobody in the building fully understood. That is a normal end state. Security work does not finish; it reaches a defensible position with a written list of what is still open.
15. The Checklist, Condensed
What follows is the version I hand to teams. Each item is phrased so that the answer is yes or no, and the no is actionable.
| Area | The state you want | Cadence | Typical effort |
|---|---|---|---|
| Accounts | Every admin account maps to one named person, and a query proves it | Monthly | 1 day to set up |
| Accounts | Second factor on admin, hosting, DNS, source control, CI, payment dashboard | Quarterly check | 1 day |
| Accounts | Leavers revoked within 24 hours, verified by quarterly reconciliation | Quarterly | Half a day |
| Access | At least three distinct roles in use; nobody on the default super-admin unless they need it | Quarterly | 1 day |
| Patching | Critical patches applied within 7 days, measured and reported | Continuous | Ongoing |
| Patching | Runtime versions are all in vendor support | Quarterly | Varies wildly |
| Dependencies | Automated advisory scanning on every build, with a triage owner | Continuous | Half a day |
| Dependencies | Checkout script allowlist enforced in CI | Continuous | 1 day |
| Secrets | No credentials in repository history; anything found is rotated | Continuous | 1 to 5 days |
| Backups | Restore tested end to end, with a recorded duration | Quarterly | 1 day per test |
| Backups | At least one copy production credentials cannot delete | Quarterly check | 1 day |
| Logging | Alerts on admin auth, privilege change, config change, file drift | Continuous | 2 to 3 days |
| Storefront | Rate limits on login, reset, and payment endpoints | Continuous | 1 day |
| Incident | One-page plan with real names and numbers, rehearsed annually | Annual | Half a day |
Effort figures assume a team that knows the estate. Add substantially for an inherited system, and add a great deal for one where the previous team has gone.
16. What This Costs, Honestly
The question I get from finance is what the programme costs, and the answer that lands is not a security answer.
Setting up everything above on a mid-sized Magento estate is somewhere between fifteen and thirty engineering days spread over a quarter, plus a modest run rate afterwards — an hour a month for reconciliation, a day a quarter for restore tests, and whatever triage the advisory scanning generates, which after tuning is usually under an hour a week.
Against that, roughly half the items pay for themselves outside security. The script inventory made checkout faster. The smoke test suite that made patching routine also made every other release safer. The backup restore test surfaced a data loss exposure that had nothing to do with attackers. The role definitions stopped a marketing contractor from accidentally disabling a payment method, which had happened twice before.
The cost of the alternative is the part nobody can price honestly in advance. On the homeware account the direct incident cost — forensics, the acquirer's requirements, agency time, re-shooting product photography — ran to a number that would have funded the entire programme roughly four times over, and that excludes two weeks of a leadership team doing nothing else.
17. Questions That Come Up
"We're on Shopify, so most of this doesn't apply, right?" The infrastructure sections mostly do not — Shopify patches its own servers and you cannot touch them. Everything about accounts, apps, staff access, secrets in your own repositories, and the code in your theme applies exactly as written. And if you have built a headless storefront or a custom checkout, more of it applies than you would like, because those are your systems.
"Isn't a WAF enough?" A WAF is a useful layer and it is not a substitute for any item on this list. It will not stop a valid login with a stolen password, which is how the opening incident happened. A WAF you deployed and never tuned is also weaker than most people assume; ask when the ruleset was last reviewed and what it blocked yesterday.
"How do I get budget for this when nothing has gone wrong?" Do the inventory first and present the four lists. In my experience the list of third parties receiving customer data, with the unowned entries highlighted, does more to unlock budget than any amount of general argument. It is concrete, it is specific to the business, and nobody has to take a security opinion on faith.
"Our developers need production database access to debug." Common, and usually solvable with better logging and a staging environment holding realistic-but-synthetic data. Where genuine production access is needed, time-limited elevation with an approval trail is the pattern. Standing production access for a whole team is a finding waiting to happen, and it also makes the account reconciliation meaningless.
"How often should we run a penetration test?" Annually, and after any significant architectural change — a replatform, a new payment provider, a move to a new hosting arrangement. What matters more than frequency is scoping: a test that only covers the public storefront will not tell you anything about the admin panel or the API, and those are usually where the interesting findings are.
"Should we run a bug bounty?" Not until the items in this article are done. A bounty programme against an estate that has not had a patching cadence produces a flood of low-severity reports and a triage burden you have not resourced. A published security contact address and a policy that says you will not sue people who report things in good faith costs nothing and is worth having from day one.
"We inherited this system and have no documentation. Where do we start?" Access first, because you can do it without understanding the code: enumerate every account on every system, disable everything you cannot attribute to a person who currently works there, and turn on 2FA. It is disruptive for a week and it removes the single largest category of risk while you learn everything else.
18. What I'd Do First
In this order, and the first three are a single day's work.
Export the admin user list and the list of accounts on your hosting console, your DNS registrar and your source control. Disable anything you cannot attribute to a named current employee or an in-contract supplier. Expect to find something.
Turn on mandatory two-factor authentication for the admin panel and the DNS registrar. Those two, today, before anything else on the list.
Run the script inventory on your live checkout page, including a real journey and not just a cold load. Find an owner for every entry. Delete the ones with no owner.
Then, over the following fortnight: get a secret scan over your repository history and rotate whatever it finds. Check whether your backups include the media directory, and whether anything with your production credentials could delete them. Set up advisory scanning in CI with the unfixed-CVE noise suppressed so people keep looking at it.
Then the quarterly rhythm: reconcile accounts, restore a backup and time it, review the third-party list, and re-read the incident plan to check the phone numbers still belong to people who work there.
The framing I would leave you with is the one from the opening incident. Nobody exploited a vulnerability on that site. An old account with a reused password logged in through the front door and edited a piece of content. Every single one of the controls that would have prevented it — individual accounts, mandatory second factor, offboarding with a technical step, quarterly reconciliation, alerting on admin logins from unusual locations, a review requirement on content that renders at checkout — is boring, cheap, and was already on somebody's list.
The difference between merchants who get compromised and merchants who do not is almost never sophistication. It is whether the boring list has an owner and a date.
Suggested & Related Reading
Explore related engineering guides from Kenneth D'Silva:
-
Comprehensive Security Hardening Checklist for Magento 2
Linux file permissions and SSH access control.
-
PCI-DSS 4.0 Compliance Checklist for E-Commerce Developers
Payment gateway iframe sandboxing and auditing.
-
Implementing a Web Application Firewall (WAF) for Ecommerce
Cloudflare & AWS WAF expression rules.
-
Architecting Secure Payment Gateways
Stripe, Adyen, and headless checkout flows.