David Lewin David Lewin
E-Commerce, Research & Ads

Catching Magecart and card skimmers on WooCommerce: file integrity scripts vs MalCare

Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.

Short answer

A Magecart skimmer is JavaScript injected into your checkout that copies card details as customers type them and sends them to the attacker. On WooCommerce it gets in through a core or plugin file, a theme, a must-use plugin, or a database option, and it often hides from logged-in admins. Catch it in three layers: compare every file with WordPress.org's official checksums and a baseline of everything else, query the database for injected scripts, and send a Content-Security-Policy header so the browser refuses to send card data to hosts you didn't list. Our tested Python script found all 10 planted changes on a real WordPress 7.1.3 and WooCommerce 11.2.1 install with no false positives, and in headless Edge an enforced CSP blocked all 5 exfiltration channels we tried. MalCare does the scanning on its own servers, covers files and database, adds a firewall and cleans up on paid plans: the better choice if nobody will read a nightly report.

A card skimmer doesn't crash anything. The checkout works, orders come in, payments clear, and for weeks or months every card number typed into your checkout is also sent to someone else. You usually find out when a card network or your payment processor tells you that your store is the common point of purchase for a batch of fraud. This guide covers how skimmers get into a WooCommerce store, three checks that catch them, and when to pay MalCare to do the watching.

How this guide was made

We tested the scanner on our machine against the real WordPress 7.1.3, WooCommerce 11.2.1 and MalCare 6.76 releases from WordPress.org, checked against the live WordPress.org checksum APIs, plus a made-up premium plugin. Then we planted 10 changes in the places skimmers and backdoors use. The planted files were harmless stand-ins: the scanner compares checksums, so payload content doesn't matter, and real web-shell code gets flagged by antivirus (Windows Defender quarantined our first test file). We tested the SQL query on MariaDB 11.4 against WordPress-shaped tables, and the CSP in headless Edge 155. MalCare's features and prices come from its pricing page and WordPress.org listing, checked in October 2026; we didn't test MalCare's detection.

1. The short answer

CheckCatchesMisses
File integrity (our script, or WP-CLI for core only)Any change to core, wordpress.org plugins, themes, premium plugins, mu-plugins, root files; PHP in uploadsSkimmers stored in the database; a backdoor already present when you make the baseline
SQL query on wp_options and wp_postsScripts injected into widgets, theme settings, header/footer and code-snippet pluginsAnything in files; scripts split up to avoid simple patterns
Content-Security-Policy, enforcedCard data sent to hosts not on your list, whatever injected the skimmerExfiltration through hosts you allowed, or by sending the browser to another page
MalCare (paid plans)Files and database, scanned on MalCare's servers, plus a firewall and one-click cleanupYou're trusting a vendor's signatures and schedule (every 24 hours on Protect, hourly on Fortify)

2. How a skimmer gets into a WooCommerce checkout

A Magecart attack on a WooCommerce store
A Magecart attack on a WooCommerce storeWay in → Injection → Checkout page → Customer types the card → ExfiltrationWay in: a vulnerable plugin, a stolen admin or hosting login, or a compromised developer machineWay ina vulnerable plugin, a stolen admin or hosting login, or acompromised developer machineInjection: core or plugin JS, a checkout template, an mu-plugin, a widget or theme setting in the database, .user.iniInjectioncore or plugin JS, a checkout template, an mu-plugin, a widgetor theme setting in the database, .user.iniCheckout page: loads only on /checkout, often skips logged-in admins so the owner never sees itCheckout pageloads only on /checkout, often skips logged-in admins so theowner never sees itCustomer types the card: fields are read as they're typed, or a fake payment form is shown over the real oneCustomer types the cardfields are read as they're typed, or a fake payment form isshown over the real oneExfiltration: fetch, sendBeacon, an image URL or a WebSocket to a lookalike domainExfiltrationfetch, sendBeacon, an image URL or a WebSocket to a lookalikedomain
Every layer in this guide targets one of these steps: file and database checks find the injection, CSP blocks the exfiltration.

The details change between campaigns, but the pattern is stable. Recent WooCommerce campaigns reported by security researchers hooked into WordPress's wp_enqueue_scripts so their script only loaded at checkout, checked for the admin bar and removed themselves when an administrator was logged in, and replaced Stripe's card fields with a fake form. That last part matters: a hosted payment field (Stripe's Payment Element, PayPal's buttons) keeps card numbers out of your page, but a skimmer can still draw its own fields on top. Hosted fields reduce your PCI scope; they don't make your checkout page safe by themselves.

Three practical consequences:

  • Checking the checkout while logged in proves nothing. Use a private window, or the checks below.
  • The injection can be anywhere code runs. Minified core JS is a favorite because nobody reads it; must-use plugins don't appear in the normal plugin list; database options survive reinstalling every plugin.
  • Removing the skimmer isn't the end. Whatever let it in (the vulnerable plugin, the leaked password) is still there until you find it.

3. Layer 1: file integrity against official checksums

WordPress.org publishes a checksum for every file in every WordPress release (api.wordpress.org/core/checksums/1.0/) and every version of every plugin it hosts (downloads.wordpress.org/plugin-checksums/). Comparing your files against those lists finds a single changed byte in a 300 KB minified file, with no signatures and no guessing. For everything WordPress.org doesn't cover (themes, premium plugins, must-use plugins, wp-config.php, .htaccess, .user.ini), the script keeps its own baseline of SHA-256 hashes and reports what changed since.

"""File integrity check for a WordPress/WooCommerce install, aimed at checkout skimmers and backdoors.
- Core files and wordpress.org plugins are compared against the official checksums (api.wordpress.org).
- Everything else in wp-content (themes, premium plugins, mu-plugins, drop-ins) is compared against a local
  baseline of SHA-256 hashes made on the first run. Keep the baseline off the web server or it can be rewritten.
- Any PHP file under wp-content/uploads is reported: WordPress never puts code there.
Python 3.9+, standard library only. Read-only: it never changes a file.
Usage: wp_integrity.py /var/www/html --baseline /root/wp-baseline.json [--accept]
Exit code 1 when something is reported. Run --accept after a reviewed update to rewrite the baseline."""
import argparse, hashlib, json, os, re, sys, urllib.error, urllib.request

CODE = ('.php', '.phtml', '.phar', '.php5', '.php7', '.js', '.mjs', '.html', '.htm', '.htaccess')
# Triage hints only: legitimate minified code matches some of these, a clean file can't explain a checksum mismatch
HINTS = {'eval': r'\beval\s*\(', 'base64': r'base64_decode|atob\s*\(', 'fromCharCode': r'fromCharCode',
         'exfil': r'sendBeacon|new\s+WebSocket|new\s+Image\(\)\.src', 'card fields': r'cc[-_]?(number|num|exp|cvv)|cardnumber|cvc',
         'remote include': r'(include|require)(_once)?\s*\(?\s*[\'"]https?://', 'hex escapes': r'(\\x[0-9a-f]{2}){20}'}
PHP_EXT = ('.php', '.phtml', '.phar', '.php5', '.php7', '.pht')
# Root files WordPress doesn't ship but attackers edit (auto_prepend_file, redirects, DB credentials): baseline them
ROOT_TRACKED = ('wp-config.php', '.htaccess', '.user.ini')
SKIP_DIRS = {'cache', 'upgrade', 'wflogs', 'node_modules', '.git'}


def fetch_json(url):
    req = urllib.request.Request(url, headers={'User-Agent': 'wp-integrity/1.0'})
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            return json.load(r)
    except urllib.error.HTTPError as e:
        if e.code == 404:
            return None
        raise


def digest(path, algo):
    h = hashlib.new(algo)
    with open(path, 'rb') as f:
        for block in iter(lambda: f.read(1 << 20), b''):
            h.update(block)
    return h.hexdigest()


def hints(path):
    try:
        text = open(path, 'rb').read(4 << 20).decode('latin-1')
    except OSError:
        return ''
    found = [k for k, p in HINTS.items() if re.search(p, text, re.I)]
    return '  [' + ', '.join(found) + ']' if found else ''


def walk(root, rel=''):
    for dirpath, dirs, files in os.walk(os.path.join(root, rel)):
        dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
        for name in files:
            yield os.path.relpath(os.path.join(dirpath, name), root).replace(os.sep, '/')


def header(path, field):
    m = re.search(r'^[ \t/*#@]*' + field + r':\s*(.+)$', open(path, encoding='utf-8', errors='replace').read(8192), re.M | re.I)
    return m[1].strip() if m else None


def check_core(wp, report):
    version = re.search(r"\$wp_version\s*=\s*'([^']+)'", open(os.path.join(wp, 'wp-includes/version.php')).read())[1]
    data = fetch_json('https://api.wordpress.org/core/checksums/1.0/?version=%s&locale=en_US' % version)
    sums = (data or {}).get('checksums') or {}
    if not sums:
        report('ERROR', 'no official checksums for WordPress %s' % version)
        return version
    for rel, md5 in sums.items():
        if rel.startswith('wp-content/'):
            continue  # default themes and plugins: the baseline covers wp-content
        p = os.path.join(wp, rel)
        if not os.path.exists(p):
            report('MISSING', 'core ' + rel)
        elif digest(p, 'md5') != md5:
            report('MODIFIED', 'core ' + rel + hints(p))
    for rel in walk(wp):
        top = rel.split('/')[0]
        if rel in sums or top == 'wp-content' or rel in ROOT_TRACKED:
            continue
        # Unknown files inside wp-admin and wp-includes are never legitimate; at the root only code is reported
        if top in ('wp-admin', 'wp-includes') or (('/' not in rel) and rel.lower().endswith(PHP_EXT)):
            report('UNKNOWN', 'core ' + rel + hints(os.path.join(wp, rel)))
    return version


def check_plugins(wp, report):
    """wordpress.org plugins against their published checksums; returns the folders the checksums cover."""
    covered = set()
    base = os.path.join(wp, 'wp-content/plugins')
    for slug in sorted(os.listdir(base)) if os.path.isdir(base) else []:
        d = os.path.join(base, slug)
        if not os.path.isdir(d):
            continue
        mains = [f for f in os.listdir(d) if f.endswith('.php') and header(os.path.join(d, f), 'Plugin Name')]
        version = mains and header(os.path.join(d, mains[0]), 'Version')
        data = version and fetch_json('https://downloads.wordpress.org/plugin-checksums/%s/%s.json' % (slug, version))
        if not data:
            continue  # premium or custom plugin: the baseline covers it
        covered.add('wp-content/plugins/' + slug + '/')
        files = data['files']
        for rel, sums in files.items():
            p = os.path.join(d, rel)
            want = sums['sha256'] if isinstance(sums['sha256'], list) else [sums['sha256']]
            if not os.path.exists(p):
                report('MISSING', 'plugin %s %s' % (slug, rel))
            elif digest(p, 'sha256') not in want:
                report('MODIFIED', 'plugin %s %s%s' % (slug, rel, hints(p)))
        for rel in walk(d):
            if rel not in files and rel.lower().endswith(CODE):
                report('UNKNOWN', 'plugin %s %s%s' % (slug, rel, hints(os.path.join(d, rel))))
    return covered


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('wp', help='WordPress root, the folder with wp-config.php')
    ap.add_argument('--baseline', required=True, help='JSON file with hashes of everything not covered by wordpress.org')
    ap.add_argument('--accept', action='store_true', help='rewrite the baseline from the current files')
    a = ap.parse_args()
    found = []

    def report(kind, what):
        found.append(kind)
        print('%-9s %s' % (kind, what))

    version = check_core(a.wp, report)
    covered = check_plugins(a.wp, report)
    current = {r: digest(os.path.join(a.wp, r), 'sha256') for r in ROOT_TRACKED if os.path.exists(os.path.join(a.wp, r))}
    for rel in walk(a.wp, 'wp-content'):
        p = os.path.join(a.wp, rel)
        if rel.startswith('wp-content/uploads/'):
            if rel.lower().endswith(PHP_EXT):
                report('UPLOADS', rel + hints(p))
            continue
        if not any(rel.startswith(c) for c in covered):
            current[rel] = digest(p, 'sha256')
    if a.accept or not os.path.exists(a.baseline):
        json.dump(current, open(a.baseline, 'w'), indent=0, sort_keys=True)
        print('baseline written: %d files' % len(current))
    else:
        old = json.load(open(a.baseline))
        for rel in sorted(set(old) | set(current)):
            if rel not in current:
                report('DELETED', rel)
            elif rel not in old:
                report('NEW', rel + hints(os.path.join(a.wp, rel)))
            elif old[rel] != current[rel]:
                report('CHANGED', rel + hints(os.path.join(a.wp, rel)))
    print('WordPress %s, %d wordpress.org plugin(s) verified, %d file(s) in the baseline, %d finding(s)'
          % (version, len(covered), len(current), len(found)))
    return 1 if found else 0


if __name__ == '__main__':
    sys.exit(main())

Run it nightly from cron as a user that can read the site but not write to it, and keep the baseline somewhere the web server user can't write. A baseline the attacker can rewrite proves nothing:

# /etc/cron.d/wp-integrity: report to the admin mailbox only when something changed
[email protected]
30 3 * * * integrity python3 /opt/wp-integrity/wp_integrity.py /var/www/html --baseline /opt/wp-integrity/baseline.json >/tmp/wpi.txt || cat /tmp/wpi.txt

After you update a premium plugin or theme, run it once by hand, read what changed, and then run it with --accept to save the new state as the baseline. Updates to core and wordpress.org plugins need nothing: the script fetches the checksums for whatever version is installed. Output from our test after planting 10 changes:

MODIFIED  core wp-includes/js/jquery/jquery.min.js  [base64, fromCharCode, exfil, card fields]
UNKNOWN   core wp-l0gin.php
UNKNOWN   core wp-includes/class-wp-cache-helper.php  [base64]
MODIFIED  plugin woocommerce templates/checkout/form-checkout.php
UNKNOWN   plugin woocommerce assets/js/frontend/checkout-helper.js  [base64, exfil, card fields]
UPLOADS   wp-content/uploads/2026/10/thumb.php
NEW       .user.ini
NEW       wp-content/mu-plugins/wp-sys-cache.php
CHANGED   wp-content/plugins/acme-gateway/assets/checkout.js  [base64, exfil, card fields]
CHANGED   wp-content/themes/twentytwentyfive/functions.php  [fromCharCode]
WordPress 7.1.3, 3 wordpress.org plugin(s) verified, 403 file(s) in the baseline, 10 finding(s)

The words in brackets are hints for triage, not verdicts. Unmodified jQuery contains fromCharCode too. What matters is the first column: an official file that doesn't match its checksum has been changed, whatever it contains.

Test results

CheckResult
Clean install (about 6,100 files: core, WooCommerce, MalCare, Akismet, default themes)0 findings: the real releases match WordPress.org's checksums exactly
Skimmer appended to core jquery.min.jsMODIFIED, with hints
Script tag added to WooCommerce's checkout/form-checkout.php templateMODIFIED
New PHP file in wp-includes, new PHP file at the rootUNKNOWN
New JS file inside the WooCommerce plugin folderUNKNOWN
PHP file in uploads/2026/10/UPLOADS
Must-use plugin that loads a script on checkout for logged-out visitorsNEW
.user.ini with auto_prepend_fileNEW
Changed theme functions.php, changed premium plugin JSCHANGED
wp-config.php, .htaccess, robots.txt, images in uploadsNot reported (config files are in the baseline)
After --acceptBaseline findings cleared; the 6 core, plugin and uploads findings remain until the files are restored
Run timeAbout 10 s for a normal run. The first run, on freshly extracted files, took under 5 minutes, most of it, we believe, antivirus scanning every new file

WP-CLI has wp core verify-checksums and wp plugin verify-checksums, which cover core and wordpress.org plugins and are a good start if you already use WP-CLI. The script adds themes, premium plugins, mu-plugins, root config files and uploads, and it never executes PHP from the site it's checking.

4. Layer 2: scripts hiding in the database

Widgets, theme settings, header/footer plugins and code-snippet plugins all store HTML or JavaScript in the database, and a skimmer stored there survives reinstalling every file. This query lists every option and post that holds a script tag or common obfuscation:

-- Options and posts that hold a script tag or common obfuscation. Change wp_ if your table prefix differs.
-- Expect a few legitimate rows (analytics in a header/footer plugin): anything else is a lead.
SELECT 'option' AS kind, option_name AS name, LENGTH(option_value) AS bytes,
       REGEXP_SUBSTR(option_value, 'https?://[a-z0-9.-]+') AS first_url
FROM wp_options
WHERE option_value REGEXP '<script|atob\\(|fromCharCode|eval\\(|createElement\\(.script|(\\\\x[0-9a-f]{2}){10}'
  AND option_name NOT LIKE '\_transient\_%' AND option_name NOT LIKE '\_site\_transient\_%'
UNION ALL
SELECT CONCAT('post:', post_type), CONCAT(ID, ' ', post_title), LENGTH(post_content),
       REGEXP_SUBSTR(post_content, 'https?://[a-z0-9.-]+')
FROM wp_posts
WHERE post_content REGEXP '<script|atob\\(|fromCharCode|eval\\(|createElement\\(.script'
  AND post_status <> 'trash' AND post_type <> 'revision'
ORDER BY bytes DESC;

Run it with wp db query < skim_db.sql or the mysql client. On our test tables (50,000 filler options plus a handful of planted rows) it took 0.16 s on MariaDB 11.4 and returned:

option       ihaf_insert_header      126  https://www.googletagmanager.com
option       widget_custom_html      109  https://cdn-jquery-static.example
option       theme_mods_storefront   103
post:wpcode  2 Header snippet         71  https://cdn-jquery-static.example
option       wc_sys_cache             59

The first row is legitimate: Google Analytics added through a header plugin. The other four are the planted injections, including one with no script tag at all (only atob) and one hex-escaped string. Transients, trashed posts and a YouTube embed weren't listed. Save the output when the site is clean; afterwards, any new row is a lead. Also look at your payment gateway settings now and then: older WooCommerce attacks simply changed the PayPal email address.

5. Layer 3: CSP, so the browser won't send the card anywhere else

File and database checks find a skimmer after it's planted. A Content-Security-Policy header limits what it can do while it's there: the browser refuses to load scripts from, or send data to, any host not on your list. We built a fake checkout with an inline skimmer that tried five ways to send a card number to another origin, and loaded it in headless Edge 155 three times:

HeaderWhat reached the attackerViolation reports
NoneAll 5: external script, fetch, sendBeacon, image URL, WebSocketNone
Content-Security-Policy-Report-OnlyAll 5connect-src, img-src, script-src-elem
Content-Security-Policy (enforced)Nothingconnect-src, img-src, script-src-elem

Two lessons. Report-Only only tells you; it blocks nothing, so it's the first step, not the end state. And the policy allowed 'unsafe-inline' scripts, as most WooCommerce checkouts must because WordPress prints inline scripts, yet the enforced policy still stopped all five channels, because connect-src and img-src control where data can go. This is the policy we tested, as an nginx header limited to the checkout:

# http {} block: the header is only sent for /checkout/ (an empty value means nginx doesn't send it)
map $request_uri $checkout_csp {
    ~^/checkout/ "default-src 'self'; script-src 'self' 'unsafe-inline' https://js.stripe.com; connect-src 'self' https://api.stripe.com; img-src 'self' data:; frame-src https://js.stripe.com; form-action 'self'; report-uri /csp-report";
    default "";
}
# server {} block. Start with -Report-Only, switch to Content-Security-Policy once the reports are clean.
# A location that has its own add_header lines doesn't inherit this one: repeat it there.
add_header Content-Security-Policy-Report-Only $checkout_csp always;

We didn't run this nginx config itself; the policy is the one the browser test enforced. Run it in Report-Only for a week or two: the reports list every host your checkout really uses (Stripe's 3D Secure frames, analytics, a reviews widget), and you add the ones you trust. Check your payment gateway's documentation for its full list; Stripe publishes one. The limits: a skimmer can still send data through a host you allowed, or send the whole browser to another page with the data in the URL. CSP makes the common skimmer fail; it doesn't make the page tamper-proof.

6. If you find a skimmer

  1. Stop the bleeding: put the checkout into maintenance mode or switch to a redirect-only payment method while you work.
  2. Keep evidence: copy the changed files, the query output and web server logs before you clean anything. Your payment processor may ask for them.
  3. Restore from known-good sources: reinstall core and wordpress.org plugins from WordPress.org, premium plugins from the vendor, and remove the injected database rows. Then run the scanner and the query again.
  4. Close the way in: update every plugin, delete unused ones, check for admin users you don't recognize, and change WordPress, hosting, SFTP and database passwords, the salts in wp-config.php, and payment gateway API keys.
  5. Tell the people who need to know: your payment processor, and depending on where you and your customers are, a data protection authority. A skimmer that copied card numbers is a personal data breach.

On PCI: since March 31, 2025, PCI DSS 4.0.1 requires script authorization and change detection on payment pages (requirements 6.4.3 and 11.6.1). The SAQ A revision of January 2025 removed those two line items for merchants whose payment form is fully hosted in an iframe, but it added an eligibility criterion: you confirm your site isn't susceptible to attacks from scripts that could affect your e-commerce system. The checks in this guide are a practical way to back that statement up. Ask your acquirer or QSA which SAQ applies to you.

7. MalCare: the managed route

MalCare installs as a plugin that syncs your site to MalCare's servers, where the scanning happens. According to MalCare, "the scan runs on our servers, so your site doesn't slow down", and it covers both files and the database. That suits small VPS and shared-hosting plans where a full scan inside PHP competes with real customers for CPU. Plans per site per year, from MalCare's pricing page in October 2026:

PlanPrice, 1 siteMalware scanFirewallCleanup
Free$0Every 7 daysBasicNo
Protect$99/yearEvery 24 hoursAdvanced, plus bot protectionNo
Repair$299/yearEvery 12 hoursReal-timeAutomatic
Fortify$499/yearEvery hourReal-timeAutomatic, plus unlimited manual fixes
  • What you get beyond our scripts: someone else maintains the signatures, the firewall blocks known attacks before they land, and on Repair and above a detected infection is cleaned without you reading diffs at midnight.
  • What to know: MalCare's firewall is plugin-based, so it runs on your server, unlike Sucuri's, which filters traffic before it reaches you. MalCare's own plugin is a wordpress.org plugin, so our script verified its files against WordPress.org's checksums in the test, and you can run both.
  • Scan frequency matters for skimmers: a skimmer that's live for a week takes far more cards than one that's live for an hour. For a store taking real volume, daily is the minimum, which is the Protect plan.

8. Our opinion: trade-offs

  • You or a developer will read a nightly report: the script plus CSP. Checksums are a stronger signal than signatures for files WordPress.org knows about, and the enforced CSP protects customers even on the day you miss the report. It costs nothing but attention.
  • Nobody will read a report: MalCare. A check nobody reads is worth nothing. Protect at $99 a year gets daily scanning and a firewall; Repair adds the cleanup most owners can't do themselves.
  • Heavy attack traffic, or you want the filtering off your server: Sucuri. Its firewall is a proxy in front of the site; see our carding attacks guide.
  • Whatever you pick, add the CSP. It's the only layer here that works in the customer's browser, at the moment the card is typed.

Related: verified backups you can restore after a cleanup, scoring fraudulent orders before they ship and monitoring SSL, checkout and API health.

9. Tools

Top picks

ToolRoleWhy it fits
MalCareCloud malware scanning, firewall and cleanup for WordPressScans files and database on its own servers, so the store's server isn't loaded. Free plan to try it; daily scans from $99 a year.
SucuriProxy firewall, monitoring and malware cleanupFilters traffic before it reaches the server, and includes cleanup. Readers get 25% off the first year.

Alternatives: free tools and references

We don't earn anything from these links.

10. Pitfalls

  • Making the baseline on a site that's already infected. The baseline accepts what's there. Make it right after a clean install or a cleanup, and treat the first run's list of non-wordpress.org files as something to read.
  • Keeping the baseline on the web server. An attacker with write access can update it. Keep it read-only for the web server user, or on another machine.
  • Testing the checkout while logged in. Many skimmers hide from admins. Use a private window.
  • Leaving CSP in Report-Only. It only reports. Switch to enforcing once the reports are clean.
  • 'unsafe-inline' in script-src isn't a reason to skip CSP. Our test shows connect-src and img-src still stop the data leaving.
  • Cleaning without closing the way in. The same vulnerable plugin or leaked password will be used again within days.
  • Nulled premium plugins. A "free" copy of a paid plugin is a common way skimmers get installed by the owner. The checksum check can't help: there's no official copy to compare against.
Bottom line

Check files against WordPress.org's checksums and your own baseline every night, query the database for injected scripts, and enforce a CSP on the checkout so a skimmer that slips through can't send card data to its own host. If nobody will read the nightly report, MalCare does the scanning off your server and cleans up on its paid plans; Sucuri moves the firewall in front of the site. In both cases, keep the CSP.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.