Security

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against Magento Open Source 2.4.9 (general availability 2026-05-12, standard support until 2029-05-31), Adobe Commerce 2.4.9 and Mage-OS 3.6.0 (released 2026-10-14, based on Magento 2.4.9), and it was checked on 2026-10-27. It covers the three self-hosted editions in detail and says where Adobe Commerce on cloud infrastructure and Adobe Commerce as a Cloud Service (ACCS) differ. The security model of this platform has two halves. The first half is operational: Magento is a large PHP code base that a merchant or an agency installs, patches and runs, so most real compromises come from unpatched core code, weak Admin access and unreviewed third-party extensions. The second half is code: every module added to the shop can open the same holes that the core closes. The page therefore goes from the patch process and the vulnerability history, through the Admin, transport and Content-Security-Policy settings, to the secure coding rules for module developers, and ends with scanning and incident response.

It follows the pages on module anatomy, on plugins, events and observers and on REST and GraphQL APIs, whose webapi.xml and authentication it refers to, and it precedes the pages on testing, coding standards and the Marketplace and on upgrades. Token types and their lifetimes are in API authentication; the payment-specific rules are in checkout and payments.

The running example is Pocket Coffee, the small coffee shop of this section. The page builds a module, PocketCoffee_Security, around the pieces a shop of that kind really needs: a signed server-to-server "order paid" notification from a payment provider, a Coffee Club sign-up form protected against CSRF, a roast date badge that has to escape what it prints, three REST routes with three authorization levels, an encrypted shared secret in the configuration, a Content-Security-Policy whitelist, and a console command that shows the framework helpers for hashing, encryption, escaping and signed tokens. Every example was installed in the local Mage-OS 3.6.0 store (PHP 8.5.12, MariaDB 11.8, OpenSearch 3, Valkey 9, developer mode) and exercised with curl and the console; the results are quoted after each one. The parts that could not be run are listed in What was run and what was not.

Who is responsible for what

Security is a shared task, and the share depends on the edition. The table lists who applies code patches and who runs the infrastructure. The statements about Adobe Commerce on cloud infrastructure come from Adobe’s shared responsibility model; the statements about the other editions follow from how they are delivered.

Edition Core code patches Servers, PHP, database, search, cache Custom code and extensions

Magento Open Source

The merchant applies security patch releases and isolated patch files that Adobe publishes for Open Source too (see Patch types and cadence). Extended support security patches are Adobe Commerce only.

The merchant or the hosting provider, including end-of-life dependencies (MySQL 8.0 reached end of support on 2026-04-30).

The merchant.

Mage-OS

The merchant installs Mage-OS releases (3.5.0 on 2026-09-08 and 3.6.0 on 2026-10-14 were security releases). Mage-OS is a distribution built on the Open Source code base, not a patch channel of Adobe.

The merchant or the hosting provider.

The merchant.

Adobe Commerce on-premises

The merchant applies security patches, isolated patch files and hotfixes; the Quality Patches Tool delivers hotfixes and quality patches.

The merchant.

The merchant.

Adobe Commerce on cloud infrastructure

Adobe ships Cloud Patches for Commerce, which are applied at deployment, and publishes the other patches; the merchant still upgrades versions, and upgrade deadlines are enforced (see Cloud enforcement).

Adobe applies server-level patches and operates the perimeter, including the Fastly web application firewall; the merchant keeps third-party dependencies on supported versions.

The merchant.

Adobe Commerce as a Cloud Service (ACCS)

Adobe, continuously: the service is versionless and has no manual patching.

Adobe.

Out-of-process apps run on Adobe Developer App Builder or elsewhere; the merchant secures them.

Two consequences follow for anyone planning a shop. First, a "managed" Magento host is a managed server, not a managed application: unless the contract says that the provider applies Adobe security patches and Mage-OS releases, the merchant has to. Second, Adobe’s own incident guidance states that most compromises are not due to an innovative attacker. Threat actors take advantage of unpatched vulnerabilities, poor passwords and weak ownership and permission settings, which is why the first sections of this page are about patching and Admin access, and why the code-level sections come later.

Patch types and cadence

Adobe delivers fixes in several shapes. They are easy to confuse, and the difference decides how fast a shop can react to a vulnerability. The table follows Adobe’s versioning policy and its page on monthly isolated security patches.

Type What it is How it is applied

Security patch release (2.4.9-p1, 2.4.8-p5)

A Composer release of the line with the suffix -pN, based on the prior full patch release. It contains the quality and security fixes of the prior release plus the security fixes created since. Security releases are published at least once a year for every supported line; Adobe may publish more.

composer require-commerce (see the page on upgrades).

Isolated security patch file

A narrow, non-cumulative code diff with the fix for one or more vulnerabilities and nothing else. Isolated files are folded into the next security patch release. They are tested only against the latest -pN release of a line, so a shop that lags behind the baseline has to move to it first.

A patch file applied in sequence (not cumulative: each file assumes the earlier ones). On cloud infrastructure the fixes arrive in the Cloud Patches for Commerce package.

Monthly isolated build (2.4.9-2026-oct)

The naming of the monthly isolated security patches since mid-2026: Adobe publishes them on Patch Tuesday, the second Tuesday of the month, for Adobe Commerce on cloud infrastructure, on-premises and Magento Open Source.

As above; the Commerce Version Tool reports which monthly patches are installed and which CVEs a shop is still exposed to.

Hotfix

An urgent, narrowly scoped fix for a critical issue such as a zero-day, offered to all customers on a supported version even if they are not on the latest patch. A hotfix is not comprehensive and can contain backward-incompatible changes.

Through the Quality Patches Tool or a ZIP or diff named in the bulletin.

Quality patch and individual patch

A targeted fix for a quality issue (individual patches contain no backward-incompatible changes). Optional.

Quality Patches Tool (magento/quality-patches, ./vendor/bin/magento-patches status, apply, revert).

The practical rules that come out of these definitions are:

  1. Subscribe to the Adobe security notifications and read each bulletin (named APSBYY-NN) on the day it appears. Bulletins give the CVSS score, a priority (priority 1 is the most urgent; Sansec reports that Adobe’s priorities correspond to a 72-hour window for priority 1 and 30 days for priority 2), the affected and fixed versions and the patch ZIPs.

  2. Keep the shop on the latest security patch release of its line, because isolated files are only tested against it.

  3. Apply isolated files in order, and re-run the status tool afterwards. The Commerce Version Tool is installed with the monthly patch as vendor/bin/patch-status. It does not apply or revert anything; it reports installed and missing patches and the CVEs the installation is still exposed to, for Adobe Commerce and for Magento Open Source.

  4. Do not rely on the version number alone: after mid-2026 a shop named 2.4.9 can be patched or unpatched depending on which monthly files were applied.

  5. Mage-OS shops follow the Mage-OS release feed instead (and check each release note for the corresponding Adobe bulletin).

Adobe’s guidance on securing a shop still says that security updates are typically released quarterly. The monthly isolated patches are more frequent than that statement, so the release schedule page is the source to follow. Full patch releases of the 2.4.x line remain annual (in May), and security patch releases are published at least annually.

Cloud enforcement

Adobe Commerce on cloud infrastructure has enforced deadlines. The security and compliance notice lists them per release line and per dependency. The consequence is spelled out: the Admin is suspended while the storefront stays online, or inbound traffic is suspended and the storefront goes offline.

Deadline Applies to Required action

2026-10-30

Dependencies: MariaDB 10.5 and below, any Elasticsearch, RabbitMQ 3.9 and below

MariaDB 10.6 or higher, OpenSearch 2.19, RabbitMQ 3.13 or higher.

2027-05-31

PHP 8.1, Redis 5 and below, OpenSearch 1.x, other unsupported MariaDB versions

PHP 8.2 or higher, Valkey 8 or higher.

2027-06-01

Release lines 2.4.4 and 2.4.5 on cloud infrastructure

Upgrade to a supported release (or migrate to ACCS); unsupported versions are no longer maintained.

2028-06-01

Release lines 2.4.6 and 2.4.7 on cloud infrastructure

The same.

2.4.8 and 2.4.9 are marked "not required at this time" on that page. Open Source and on-premises shops are not subject to these deadlines, but they are not subject to the protection either: Adobe’s lifecycle policy does not provide security or quality fixes for third-party dependencies that reach end of life while a release is still supported, so an on-premises shop that stays on MySQL 8.0 after 2026-04-30 or on Elasticsearch 7.17 after 2026-01-15 carries that risk itself.

Vulnerability and incident timeline

The history below is the best argument for the patching rules. It is limited to vulnerabilities that were exploited in the wild or that Adobe rated critical, with the data points that can be checked on primary sources (NVD records, the CISA Known Exploited Vulnerabilities catalog and Adobe bulletins). The figures that come from security vendors are marked as vendor figures.

Timeline from September 2025 to October 2026 with eight Adobe bulletins and four exploitation events, plus the landmark vulnerabilities of 2022 and 2024

Landmarks before 2025

  • Card skimmers. Client-side credit card skimmers, often called Magecart attacks, are malicious scripts injected into the storefront, typically into the absolute header or footer configured in the Admin, that serialise form fields and send them to a third-party endpoint. Adobe’s guidance describes the pattern and recommends Content-Security-Policy and Subresource Integrity on payment pages (see Content Security Policy and Subresource Integrity); the platform added both for exactly this reason.

  • CVE-2022-24086. An improper input validation flaw in checkout, CVSS 9.8, published on 2022-02-16 and in the CISA catalog since 2022-02-15.

  • CVE-2024-34102, "CosmicSting". An XML external entity flaw (XXE) that leads to remote code execution, CVSS 9.8, published on 2024-06-13 as APSB24-40 and in the CISA catalog since 2024-07-17. The lesson that Adobe’s later bulletins repeat (APSB26-146 does) is that patching is not enough after an exploited remote code execution: the encryption key and the other secrets have to be rotated (see Incident response).

SessionReaper, 2025

CVE-2025-54236, nicknamed SessionReaper, is described by NVD as improper input validation that allows session takeover, CVSS 3.1 base score 9.1 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N). Adobe fixed it on 2025-09-09 in bulletin APSB25-88 with an emergency hotfix rated priority 2. According to Sansec, a security vendor, the first attacks followed on 2025-10-22, after a research firm published its analysis; Adobe then confirmed abuse and raised the priority to 1, and CISA added the CVE to its catalog on 2025-10-24. The scale figures that circulated (about 31 percent of stores reached on 2025-10-24, 81 percent visited by 2025-11-01) are Sansec’s estimates, not Adobe’s, and Adobe’s own knowledge-base article for the bulletin still says that Adobe had no evidence of exploitation, so it should not be quoted as current. The "unauthenticated remote code execution" description comes from the researchers' analysis (file-based session storage), while NVD says session takeover. The lasting advice from the incident is the same as always: patch within days, and do not store sessions in files on a server that also serves untrusted input; the local store here keeps sessions in Valkey.

2026

Adobe published seven bulletins for Commerce and Magento in 2026 up to the check date, and they show the cadence of the new monthly model:

Bulletin Published Content Fixed in

APSB26-05

2026-03-10

19 CVEs, highest CVSS 8.7.

2.4.8-p4, 2.4.7-p9, 2.4.6-p14

APSB26-49

2026-05-12

15 CVEs, highest 8.7; published on the general availability day of 2.4.9.

2.4.9, 2.4.8-p5, 2.4.7-p10, 2.4.6-p15

APSB26-73

2026-07-14

14 CVEs, highest 9.6 (CVE-2026-48356, unrestricted file upload), priority 2, no known exploits.

2.4.9-2026-jul and the other lines' July builds

APSB26-92

2026-08-11

7 CVEs, highest 9.1 (CVE-2026-71362, incorrect authorization without authentication), priority 2. CISA added this CVE to its catalog on 2026-09-24.

2.4.x-2026-aug

APSB26-146

2026-09-07

One CVE: CVE-2026-75650, template-engine injection (CWE-1336), CVSS 10.0, priority 1, unauthenticated arbitrary code execution; Adobe states that it is aware of exploitation in the wild; CISA catalog 2026-09-08. Affects Commerce 2.4.4 to 2.4.9, Open Source 2.4.6 to 2.4.9 and B2B 1.3.3 to 1.5.3.

Hotfix VULN-39341-composer-patches.zip (and per-line patch files)

APSB26-138

2026-09-08

8 CVEs, highest 9.3 (stored cross-site scripting, CVE-2026-76200 and CVE-2026-76201), priority 2. Open Source is fixed only for 2.4.7 and later. The APSB26-146 hotfix has to be applied as well.

2.4.x-2026-sep

APSB26-153

2026-10-13

11 CVEs, highest 10.0 (CVE-2026-103849, template injection leading to unauthenticated remote code execution), plus CVE-2026-92909 (9.8, B2B) and several stored XSS issues; priority 2, no known exploits.

2.4.x-2026-oct, 2.4.10-alpha1; Open Source fixed for 2.4.7 to 2.4.9

The Adobe bulletin pages return an access-denied status to some automated clients, so a link checker may flag them; they open in a browser. NVD and the CISA catalog are the places to check dates and scores mechanically.

What the 2026 sequence teaches:

  • The two CVSS 10.0 vulnerabilities of the year (CVE-2026-75650 and CVE-2026-103849) are both template injection that leads to unauthenticated remote code execution, and the highest score of the July bulletin is an unrestricted file upload. Template rendering and uploads are the places where the rules of Secure coding rules apply.

  • A critical bulletin can arrive off-cycle: APSB26-146 was published on a Monday, the day before the next scheduled bulletin.

  • The hotfix model means that a shop on "the latest" version is still vulnerable until the file is applied. The check for the APSB26-146 fix is vendor/bin/magento-patches -n status | grep "39341\|Status" on Adobe Commerce, and vendor/bin/patch-status for the monthly patches.

  • A shop that applied a patch after a known exploit has to treat the encryption key, Admin passwords, integration tokens, OAuth secrets, payment gateway keys, database credentials and SSH keys as exposed, and rotate them (see Incident response).

Admin access

The Admin panel is the highest-value target of the shop, and most of the hardening that does not depend on code is about it. Adobe’s launch best practices rank these items as the priority recommendations.

A non-default Admin URI

setup:install takes --backend-frontname. If the option is omitted the installer picks a random URI such as admin_jkhgdfq; Adobe recommends the random value. The local store uses /admin_local. The effect is easy to see with curl: the usual names answer 404, and only the configured one serves the login page.

for p in /admin /backend /admin_local /index.php/admin; do
  printf "%-18s %s\n" $p "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8082$p)"
done

Output on the local store:

/admin             404
/backend           404
/admin_local       200
/index.php/admin   404

A hidden URI is not a control by itself (it is discoverable from logs and from the HTML of other pages), it just removes the noise of scripts that probe the default names. With the Add Secret Key to URLs setting every Admin link also carries a per-session key.

Two-factor authentication

Native two-factor authentication (2FA) for the Admin comes from the module Magento_TwoFactorAuth. The first time an Admin user signs in, they must set up every provider that is required, and afterwards one of the configured providers is needed for each sign-in. The 2FA configuration applies to the whole installation, not per website. The providers are Google Authenticator (time-based one-time passwords), Duo Security (SMS and push), Authy and U2F devices such as YubiKey. The settings are under Stores > Settings > Configuration > Security > 2FA.

Setting Configuration path or note Behaviour

Providers to use

twofactorauth/general/force_providers

Which providers are required. With more than one, each user must configure each of them.

Retry attempt limit

twofactorauth/general/twofactorauth_retry (since 2.4.8), default 10

Failed one-time password entries before the account is temporarily disabled.

Lockout time (seconds)

twofactorauth/general/auth_lock_expire (since 2.4.8), default 300

How long the account stays disabled.

Google: OTP window

twofactorauth/google/otp_window before 2.4.8; twofactorauth/google/leeway from 2.4.8, default 29

In 2.4.6 the value counted the past and future codes that remained valid; from 2.4.7 it is the number of seconds an expired code is still accepted, and it must be below 30. The Google Authenticator library changed to a "leeway" in seconds, so a store upgraded to 2.4.8 has to set the new path.

Duo Security

Client ID, client secret, integration key, secret key, API hostname

2.4.8 moved to Duo Web SDK v4 and the Universal Prompt, so the Client ID and the secret are now required.

U2F

WebApi Challenge Domain

Domain used for the WebAuthn challenge (the store domain by default).

Configuration email URL for the web API

Global

Alternate configuration link for custom implementations; the template uses the placeholder :tfat.

Three details matter in practice. First, 2FA also gates the Admin token endpoint: with the module enabled, POST /rest/V1/integration/admin/token answers "Please ask an administrator with sufficient access to configure 2FA first" until the user has set up a provider, so integrations should use integration tokens or an IMS flow instead of admin tokens (see API authentication). Second, stores that enable Adobe Identity Management Services (IMS) authentication have the native 2FA disabled, because IMS handles the authentication; that is the supported way to have no native 2FA, and the module cannot be turned off from the Admin. Third, the local store used for this page has the module disabled with module:disable Magento_TwoFactorAuth, which is the usual local-development shortcut and not a configuration for a real shop.

2.4.9 added blocks that let an Admin user configure additional 2FA providers by verifying with an already configured provider or with an emailed link; that streamlines the setup and needs no action from the merchant.

Admin session, password and lockout settings

Stores > Settings > Configuration > Advanced > Admin > Security holds the rest of the Admin account controls. The table lists the fields and the values that the local Mage-OS 3.6.0 store reported with bin/magento config:show.

Field Path Value seen locally Notes

Add Secret Key to URLs

admin/security/use_form_key

1

Per-session key in every Admin URL. Enabled by default.

Admin Session Lifetime (seconds)

admin/security/session_lifetime

900

Must be 60 or more; an inactive session ends after this time.

Maximum Login Failures to Lockout Account

admin/security/lockout_failures

6

Leave the field empty for unlimited attempts (not recommended).

Lockout Time (minutes)

admin/security/lockout_threshold

30

How long the account stays locked.

Password Lifetime (days)

admin/security/password_lifetime

90

Empty means unlimited.

Password Change

admin/security/password_is_forced

1 (Forced)

Forced or Recommended after account setup.

Minimum Admin Password Length

admin/security/minimum_password_length

7

Added in 2.4.9; the minimum allowed value is 7. Changing it affects password changes, user creation (also from the CLI) and resets.

Admin Account Sharing

admin/security/admin_account_sharing

not read

No prevents the same account from signing in on two devices.

Password Reset Protection Type

Admin > Security

not read

By IP and Email, By IP, By Email or None.

The Admin login can also be protected by a CAPTCHA or Google reCAPTCHA. On the local store admin/captcha/enable and customer/captcha/enable both reported 1. The reCAPTCHA modules (Magento_ReCaptchaAdminUi, Magento_ReCaptchaCheckout, Magento_ReCaptchaWebapiRest, Magento_ReCaptchaWebapiGraphQl and more) cover the forms and, since 2.4.9, the REST and GraphQL customer account creation too: when CAPTCHA is enabled for the Create Account form, API account creation is protected as well, and updateCustomer, updateCustomerV2, contactUs and applyCouponsToCart gained reCAPTCHA. The brute-force rule is that every interface of a protected function needs the protection: a controller behind a challenge and a token mutation without one is still a login oracle.

Roles, ACL and locking configuration

Give each Admin user the smallest role that does their work (System > Permissions > User Roles). Roles are built from ACL resources declared by modules in etc/acl.xml, and the module section below shows how a custom page and a REST route are tied to a resource. Adobe also recommends locking critical configuration values so that they cannot be changed from the Admin: bin/magento config:set --lock-env <path> <value> writes the value to app/etc/env.php, and --lock-config writes it to app/etc/config.php.

The command that does the opposite deserves a warning: bin/magento app:config:dump copies the whole configuration into config.php and env.php, which locks every value in the Admin and makes the files the source of truth for websites and stores too. It exists for deployment pipelines that import configuration, and it should not be run casually on a live store.

Exposure on the server

The web root must be pub/, so that app/, var/, vendor/, dev/ and composer.json are not reachable. File ownership and permissions follow the installation guide (the web user can write var/, generated/, pub/static/ and pub/media/ in developer mode; in production mode the code directories should be read-only). A quick probe from outside shows what the local store answers. Only the status codes are printed, never the bodies.

for p in /app/etc/env.php /composer.json /var/log/system.log /dev/tests/ /.git/config /phpinfo.php \
         /setup/ /downloader/ /robots.txt /magento_version /rest/V1/modules /graphql; do
  printf "%-26s %s\n" $p "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8082$p)"
done
/app/etc/env.php           404
/composer.json             404
/var/log/system.log        404
/dev/tests/                404
/.git/config               404
/phpinfo.php               404
/setup/                    404
/downloader/               404
/robots.txt                200
/magento_version           200
/rest/V1/modules           401
/graphql                   400

Two entries are not 404 by design. /magento_version answers with the product name and the major and minor version (Mage-OS/3.6 (Community) here; Adobe builds print Magento/2.4 (Community) or (Enterprise)), which tells a scanner the edition, so many shops block it at the web server. GraphQL answers 400 to a bare GET, and a POST with { __schema { queryType { name } } } returned the schema, because GraphQL introspection is enabled by default: that is useful to clients and to attackers alike, and it is another reason for the alias limit (10) and the query length limit (1,048,576 characters) that 2.4.9 introduced for GraphQL (see REST and GraphQL APIs).

The same response headers show what the application sets on its own:

X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
X-Frame-Options: SAMEORIGIN
Content-Security-Policy-Report-Only: font-src ... (storefront home page)

X-Frame-Options: SAMEORIGIN is the default clickjacking defence and can be changed in env.php (x-frame-options). X-XSS-Protection is obsolete in current browsers. The application does not send Strict-Transport-Security, Referrer-Policy or Permissions-Policy on its own (the local store has web/secure/enable_hsts empty): those are web server or CDN settings, or the Admin options under Stores > Configuration > General > Web > Default Layout and Secure settings.

Behind a load balancer on a cluster, the exposure rules also cover the admin URL, the client IP, secrets (from a secret store, not env.php in the image) and the managed services' security groups; see Cloud: Autoscaling, Observability, Backups and Security and Cloud: Managed Database (RDS and Aurora).

Content Security Policy and Subresource Integrity

A Content-Security-Policy (CSP) is a response header that lists the origins from which a page may load scripts, styles, frames and other resources, and that tells the browser to refuse the rest. For a shop it is the main defence against card skimmers and stored XSS. The module Magento_Csp provides it for the storefront and the Admin, separately.

Modes and defaults

CSP can run in report-only mode (violations are logged in the browser console or sent to a reporting endpoint, nothing is blocked) or in restrict mode (violations are blocked). Since 2.4.7, payment pages in the storefront and the Admin are in restrict mode and everything else is report-only; before 2.4.7 all pages were report-only. The enforced header for payment pages does not contain 'unsafe-inline' in script-src, so only whitelisted inline scripts run. eval() is still allowed. The mode of an area is the configuration path csp/mode/<area>/report_only (0 restricts, 1 reports) with the area storefront or admin, and a page can have its own mode by using its full action name: the checkout page, the multishipping billing page and the Admin order creation page each declare report_only 0 in their module’s config.xml.

The local store shows this on the wire:

curl -sI http://localhost:8082/ | grep -io '^content-security-policy[a-z-]*'
curl -sI http://localhost:8082/checkout/ | grep -io '^content-security-policy[a-z-]*'
Content-Security-Policy-Report-Only
Content-Security-Policy

The first line is the home page (report only), the second the checkout (enforced).

Whitelisting hosts

A module that loads a script from a payment widget has to declare the host, in the policy that needs it and nowhere else. Adding a host to default-src when only a script is loaded is the typical overreach. The declaration is etc/csp_whitelist.xml:

<?xml version="1.0"?>
<csp_whitelist xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
               xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Csp:etc/csp_whitelist.xsd">
    <policies>
        <policy id="script-src">
            <values>
                <value id="pocketcoffee-widgets" type="host">https://widgets.pocketcoffee.example.test</value>
            </values>
        </policy>
        <policy id="connect-src">
            <values>
                <value id="pocketcoffee-api" type="host">https://api.pocketcoffee.example.test</value>
            </values>
        </policy>
    </policies>
</csp_whitelist>

The policies that can be configured are default-src, base-uri, child-src, connect-src, font-src, form-action, frame-ancestors, frame-src, img-src, manifest-src, media-src, object-src, script-src and style-src, and a value is either a host (type="host") or an inline-content hash (type="hash" with algorithm="sha256"). After a cache flush the header of the module’s page contained the two whitelisted hosts (https://widgets.pocketcoffee.example.test in script-src and https://api.pocketcoffee.example.test in connect-src). The schema csp_whitelist.xsd validated this file on Mage-OS 3.6.0; Adobe’s page of backward-incompatible changes for 2.4.9 lists module-csp/etc/csp_whitelist.xsd among removed XSD files, so the xsi:noNamespaceSchemaLocation URN of this file has to be checked against an Adobe 2.4.9 installation before an upgrade (see upgrades).

Restricting a custom page

The module restricts its own page to restrict mode with no inline scripts and no inline event handlers by declaring the page’s action name in etc/config.xml:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
    <default>
        <pocketcoffee_security>
            <callback>
                <tolerance_seconds>300</tolerance_seconds>
            </callback>
        </pocketcoffee_security>
        <csp>
            <mode>
                <storefront_pcsec_index_index>
                    <report_only>0</report_only>
                </storefront_pcsec_index_index>
            </mode>
            <policies>
                <storefront_pcsec_index_index>
                    <scripts>
                        <inline>0</inline>
                        <event_handlers>0</event_handlers>
                    </scripts>
                </storefront_pcsec_index_index>
            </policies>
        </csp>
    </default>
</config>

The path segment storefront_pcsec_index_index is the area plus the full action name (pcsec_index_index). A controller can also implement Magento\Csp\Api\CspAwareActionInterface and return additional policies from modifyCsp(). The policies keys under a page name (scripts/inline, scripts/event_handlers) control the 'unsafe-inline' and event-handler allowances of that page.

Inline scripts, event handlers and styles

Under a restrictive policy an inline <script> is blocked unless the framework signs it. The class Magento\Framework\View\Helper\SecureHtmlRenderer (the $secureRenderer variable in .phtml templates) does that, and since 2.4.7 the framework provides a per-request nonce through Magento\Csp\Helper\CspNonceProvider::generateNonce(), and the renderer used it on the local store; a hash is the alternative when the content is static. Use renderTag() for script and style tags, renderEventListenerAsTag() for onclick-style attributes and renderStyleAsTag() for style attributes. The roast date badge template of the module uses all three, and the .phtml also shows the three escaping calls used for output:

<?php
/**
 * @var \PocketCoffee\Security\Block\Badge $block
 * @var \Magento\Framework\Escaper $escaper
 * @var \Magento\Framework\View\Helper\SecureHtmlRenderer $secureRenderer
 */
?>
<div class="pocketcoffee-badge" id="pc-badge">
    <span class="label"><?= $escaper->escapeHtml($block->getLabel()) ?></span>
    <a href="<?= $escaper->escapeUrl($block->getMoreUrl()) ?>">
        <?= $escaper->escapeHtml(__('More about our roasts')) ?>
    </a>
</div>
<form action="<?= $escaper->escapeUrl($block->getUrl('pcsec/club/join')) ?>" method="post" id="pc-join">
    <?= $block->getBlockHtml('formkey') ?>
    <input type="email" name="email" required>
    <button type="submit"><?= $escaper->escapeHtml(__('Join the Coffee Club')) ?></button>
</form>
<?= /* @noEscape */ $secureRenderer->renderTag(
    'script',
    ['type' => 'text/javascript'],
    "document.getElementById('pc-badge').dataset.ready = 'yes';",
    false
) ?>
<?= /* @noEscape */ $secureRenderer->renderEventListenerAsTag(
    'onclick',
    "this.classList.toggle('pc-open');",
    '#pc-badge'
) ?>
<?= /* @noEscape */ $secureRenderer->renderStyleAsTag('border: 1px solid #6f4e37; padding: 8px', '#pc-badge') ?>

The comment /* @noEscape */ before the renderer calls is required by the static test (see The phtml XSS sniff), because the sniff cannot know that the renderer output is already safe. The page that this template belongs to is served with the enforcing header. These are the observed results (the whitelisted hosts and a nonce are present, and the policy has no report-only suffix):

Content-Security-Policy: ... script-src ... js.pay.example.com https://widgets.pocketcoffee.example.test 'self' 'unsafe-eval' 'nonce-...' 'sha256-...' ...
Content-Security-Policy: ... connect-src ... https://api.pocketcoffee.example.test ...

and the renderer added the nonce to every inline script that it produced:

<script type="text&#x2F;javascript" nonce="...">document.getElementById('pc-badge').dataset.ready = 'yes';</script>

Using inline scripts and styles at all is discouraged in favour of UI components and classes; the renderer exists for the cases where a dynamic value really has to reach inline code. Scripts and styles entered in the Admin (the Scripts and Style Sheets field of the HTML Head section of a design configuration) must be whitelisted by hash as well, which is the reason why a skimmer cannot simply be pasted there on a payment page.

Reporting and troubleshooting

Violations can be sent to an endpoint with csp/mode/<area>/report_uri (Stores > Configuration > Security > Content Security Policy; the Admin order creation and storefront checkout pages can have their own URIs and fall back to the area default). A browser message such as "Refused to execute inline script because it violates the following Content Security Policy directive: script-src" means a script was not rendered through SecureHtmlRenderer or the nonce provider; fix the template, do not add 'unsafe-inline'.

Subresource Integrity

Subresource Integrity (SRI) lets the browser verify that a script it fetched is the file the shop meant to serve. To meet the script-integrity requirement of PCI DSS 4.0 on payment pages, Magento 2.4.4-p9, 2.4.5-p8, 2.4.6-p6, 2.4.7, 2.4.8 and later provide integrity hashes for the JavaScript files in the local filesystem. The hashes are generated when static content is deployed: in 2.4.9 they cover minified, bundled and merged files and every deployment strategy (standard, quick, compact), and they are stored per area, theme and locale in pub/static/<area>/<vendor>/<theme>/<locale>/sri-hashes.json (merged runtime files in pub/static/_cache/merged/sri-hashes.json); before 2.4.8 they were kept in the cache. SRI is enabled by default on the payment pages only. To enable it on another page, a module adds a layout file that includes Magento_Csp::js/sri.js and the block Magento\Csp\Block\Sri\Hashes (template Magento_Csp::sri/hashes.phtml) to the head.additional container of that page. There is no SRI generation for remote scripts; the hash of a remote file can be computed with base64_encode(hash('sha256', $content, true)) and the tag needs both integrity and crossorigin attributes.

Secure coding rules

Adobe’s developer documentation has a page for each class of vulnerability. The module PocketCoffee_Security implements the rules that matter for a shop of this kind and tests them. The module skeleton is the one from the page on module anatomy; only what is specific to security is shown here.

Declaring the module

registration.php, composer.json and etc/module.xml are the usual three files. The sequence makes Magento_Csp and Magento_Config load first, because the module overrides their configuration:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="PocketCoffee_Security">
        <sequence>
            <module name="Magento_Csp"/>
            <module name="Magento_Config"/>
        </sequence>
    </module>
</config>

The module was installed with bin/magento module:enable PocketCoffee_Security and bin/magento setup:upgrade; both reported success and bin/magento setup:db:status reported "All modules are up to date".

Escaping output

Cross-site scripting (XSS) is injected markup that a browser runs. There are three kinds: persisted (the value comes from the database), reflected (the value comes from the request and is echoed) and DOM-based (client-side only). The rule of the documentation is to validate input but to escape output as close as possible to the place where it is printed, in the encoding of that place, because the same value can be harmless in JSON and dangerous in HTML.

The class Magento\Framework\Escaper (the $escaper variable in templates) has one method per context:

Context Method Notes

Text between tags

escapeHtml($value, $allowedTags = null)

Allowed tags keep id, class, href, style and title; embed, iframe, video, source, object, audio, script and img are never allowed. Use it for translated strings that contain a link: escapeHtml(__('... <a href="%1">...'), ['a']).

HTML attribute value

escapeHtmlAttr($value)

Also for JSON inside a data-bind attribute.

URL in href or src

escapeUrl($value)

Removes a scheme that is not allowed (see the run below).

JavaScript string

escapeJs($value)

JavaScript inside an attribute must pass escapeJs first and escapeHtmlAttr second.

JSON in a script block

none

Output that is already valid JSON from json_encode needs no further escaping inside a script tag.

Output that already is HTML

none, by naming convention

Methods named getTitleHtml() or getHtmlTitle() return HTML that is escaped upstream.

The local run of the console command shows the same hostile string, <script>alert(1)</script>"', in four contexts:

escapeHtml    : &lt;script&gt;alert(1)&lt;/script&gt;&quot;&#039;
escapeHtmlAttr: &lt;script&gt;alert&#x28;1&#x29;&lt;&#x2F;script&gt;&quot;&#x27;
escapeJs      : <script>alert(1)</script>"'
escapeUrl js  : ':alert(1)'

The last line is escapeUrl('javascript:alert(1)'): the scheme is removed, so a link built from a request parameter cannot execute script. The page then behaves in the same way over HTTP. With ?label=<img src=x onerror=alert(1)> and ?more=javascript:alert(1) the rendered HTML was:

<span class="label">&lt;img src=x onerror=alert(1)&gt;</span>
<a href=":alert(1)">

Knockout templates have a naming rule instead: a property bound as inner HTML must be named with the suffix UnsanitizedHtml, and a static test enforces it. When DOM nodes are created in JavaScript, set innerText and attributes instead of innerHTML. UI component data providers return user data that is rendered for component linking, so values that must be taken literally are listed in a __disableTmpl array of the data provider’s result.

The phtml XSS sniff

The Magento coding standard has a sniff, Magento2.Security.XssTemplate, that finds every echo in a template and decides whether the output is escaped. It accepts an $escaper or block escape method, a cast to (int) or (bool), count(), string literals without variables, methods whose name contains Html, and an explicit comment, /* @noEscape */ (output does not need escaping) or /* @escapeNotVerified */ (escaping has not been checked and should be). Everything else is an error. The module’s template passes after the renderer calls got the @noEscape comment (without it the sniff reported three errors, one for each renderer call). A bad template shows the other outcomes:

<?php
/** @var \Magento\Framework\View\Element\Template $block */
?>
<h1><?= $block->getTitle() ?></h1>
<p><?= $_GET['name'] ?></p>
<p><?= $block->escapeHtml($block->getName()) ?></p>
<p><?= /* @noEscape */ $block->getRawHtml() ?></p>

Run with vendor/bin/phpcs --standard=Magento2 --extensions=php,phtml:

FILE: /tmp/bad/bad.phtml
 4 | ERROR   | [ ] Unescaped output detected.
 5 | ERROR   | [ ] Unescaped output detected.
 5 | ERROR   | [ ] Direct use of $_GET Superglobal detected.
 6 | WARNING | [x] Using escapeHtml on $block is deprecated. Please use equivalent method on $escaper

Line 6, which calls escapeHtml() on $block, is accepted as escaped but reported as a warning ("Using escapeHtml on $block is deprecated. Please use equivalent method on $escaper", and phpcbf can fix it). Line 7 is accepted because of the comment, and the comment is a statement that a reviewer has to verify.

Form keys and CSRF

Cross-site request forgery makes a logged-in browser send a request the user did not intend. The framework’s defence is the form key. A controller opts in to the check by implementing one or more of the marker interfaces HttpGetActionInterface, HttpPostActionInterface, HttpPutActionInterface and so on: the framework then denies other HTTP methods, and for any method other than GET it requires a valid form_key. The key is added to forms by lib/web/mage/common.js; in a template it is $block->getBlockHtml('formkey'), in JavaScript the global FORM_KEY, and on the server Magento\Framework\Data\Form\FormKey. The Coffee Club sign-up handler needs nothing for the check except the interface:

<?php

declare(strict_types=1);

namespace PocketCoffee\Security\Controller\Club;

use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\Result\Json;
use Magento\Framework\Controller\Result\JsonFactory;

/**
 * Coffee Club sign-up form handler. Implementing HttpPostActionInterface is all it takes to get the
 * built-in form key (CSRF) check: a POST without a valid form_key never reaches execute().
 */
class Join implements HttpPostActionInterface
{
    /**
     * @param RequestInterface $request
     * @param JsonFactory $jsonFactory
     */
    public function __construct(
        private readonly RequestInterface $request,
        private readonly JsonFactory $jsonFactory
    ) {
    }

    /**
     * @inheritDoc
     */
    public function execute(): Json
    {
        $email = filter_var((string)$this->request->getParam('email'), FILTER_VALIDATE_EMAIL);
        $result = $this->jsonFactory->create();
        if ($email === false) {
            return $result->setHttpResponseCode(400)->setData(['error' => 'invalid_email']);
        }

        return $result->setData(['joined' => true]);
    }
}

The page that is served from the full-page cache has no per-session key in the HTML; the browser sets a form_key cookie with JavaScript and the framework compares it with the submitted value. The five cases below were run with curl against the local store:

K=AbCdEfGh12345678
curl -s -X POST localhost:8082/pcsec/club/join/ -H "Cookie: form_key=$K" -d "email=ada@example.test&form_key=$K"
curl -s -X POST localhost:8082/pcsec/club/join/ -H "Cookie: form_key=$K" -d "email=nope&form_key=$K"
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8082/pcsec/club/join/ -d "email=ada@example.test&form_key=$K"
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8082/pcsec/club/join/ -H "Cookie: form_key=$K" -d "email=ada@example.test"
curl -s -o /dev/null -w '%{http_code}\n' localhost:8082/pcsec/club/join/
{"joined":true}           (cookie and parameter agree: 200)
{"error":"invalid_email"} (valid key, bad input: 400)
302                       (parameter only: redirect to the home page, execute() never ran)
302                       (cookie only: redirected)
404                       (GET on a POST-only controller)

A request that carries the X-Requested-With: XMLHttpRequest header skips the form key check in the default validator: the same POST without any key returned {"joined":true}. The header cannot be set by a cross-site form, and browsers only allow it in cross-origin requests with a CORS preflight, so this is a deliberate exception for AJAX, but it means that an endpoint that is reachable by JavaScript must do its own authorization and not lean on the form key.

Callbacks that cannot send a form key

A payment provider calling the shop cannot know the form key, so a controller that must accept POST from a server replaces the check. The class implements Magento\Framework\App\CsrfAwareActionInterface and decides in validateForCsrf() whether the request is valid (returning true, false, or null for the default check), and createCsrfValidationException() supplies the response for a rejected one. The right replacement for the form key is a signature that only the provider and the shop can compute: HMAC-SHA256 over the timestamp and the raw body, compared with hash_equals(), with a tolerance window against replay. The verifier reads the shared secret from encrypted configuration and fails closed when it is not configured:

<?php

declare(strict_types=1);

namespace PocketCoffee\Security\Model;

use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Encryption\EncryptorInterface;

/**
 * Verifies the signature of a server-to-server notification.
 *
 * Header format: "t=<unix timestamp>,v1=<hex HMAC-SHA256 of '<timestamp>.<raw body>'>".
 */
class SignatureVerifier
{
    public const XML_PATH_SECRET = 'pocketcoffee_security/callback/secret';
    public const XML_PATH_TOLERANCE = 'pocketcoffee_security/callback/tolerance_seconds';

    /**
     * @param ScopeConfigInterface $scopeConfig
     * @param EncryptorInterface $encryptor
     */
    public function __construct(
        private readonly ScopeConfigInterface $scopeConfig,
        private readonly EncryptorInterface $encryptor
    ) {
    }

    /**
     * Check the header against the raw request body.
     *
     * @param string $header Value of the X-Pocket-Signature header
     * @param string $rawBody Raw request body, exactly as received
     * @param int|null $now Current time, injectable for tests
     * @return bool
     */
    public function isValid(string $header, string $rawBody, ?int $now = null): bool
    {
        $secret = $this->getSecret();
        if ($secret === '') {
            return false; // Not configured: fail closed.
        }

        $parts = [];
        foreach (explode(',', $header) as $item) {
            $pair = explode('=', trim($item), 2);
            if (count($pair) === 2) {
                $parts[$pair[0]] = $pair[1];
            }
        }
        if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
            return false;
        }

        $tolerance = (int)$this->scopeConfig->getValue(self::XML_PATH_TOLERANCE);
        if (abs(($now ?? time()) - (int)$parts['t']) > $tolerance) {
            return false; // Replay protection.
        }

        $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);

        return hash_equals($expected, $parts['v1']);
    }

    /**
     * Read and decrypt the shared secret.
     *
     * @return string
     */
    private function getSecret(): string
    {
        $stored = (string)$this->scopeConfig->getValue(self::XML_PATH_SECRET);

        return $stored === '' ? '' : $this->encryptor->decrypt($stored);
    }
}

The controller delegates to it and then handles only validated data. Note that it logs typed values, not the raw body, and that the order number is reduced to the characters of a Pocket Coffee order number before it reaches the log (log injection is the same class of problem as XSS):

<?php

declare(strict_types=1);

namespace PocketCoffee\Security\Controller\Callback;

use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\Request\Http as HttpRequest;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\Result\Json;
use Magento\Framework\Controller\Result\JsonFactory;
use Magento\Framework\Phrase;
use Psr\Log\LoggerInterface;
use PocketCoffee\Security\Model\SignatureVerifier;

/**
 * Receives "order paid" notifications from the payment provider.
 *
 * The provider cannot send a form key, so the standard CSRF check is replaced by a signature check.
 */
class Receive implements HttpPostActionInterface, CsrfAwareActionInterface
{
    /**
     * @param HttpRequest $request
     * @param JsonFactory $jsonFactory
     * @param SignatureVerifier $verifier
     * @param LoggerInterface $logger
     */
    public function __construct(
        private readonly HttpRequest $request,
        private readonly JsonFactory $jsonFactory,
        private readonly SignatureVerifier $verifier,
        private readonly LoggerInterface $logger
    ) {
    }

    /**
     * @inheritDoc
     */
    public function execute(): Json
    {
        $data = json_decode($this->request->getContent(), true);
        if (!is_array($data) || !isset($data['order_number'], $data['total'])) {
            return $this->jsonFactory->create()->setHttpResponseCode(400)->setData(['error' => 'bad_payload']);
        }

        // Log only validated, typed values, never the raw body.
        $this->logger->info('PocketCoffee order paid', [
            'order' => preg_replace('/[^A-Z0-9-]/', '', (string)$data['order_number']),
            'total' => round((float)$data['total'], 2),
        ]);

        return $this->jsonFactory->create()->setHttpResponseCode(202)->setData(['status' => 'accepted']);
    }

    /**
     * @inheritDoc
     */
    public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException
    {
        $result = $this->jsonFactory->create()->setHttpResponseCode(401)->setData(['error' => 'invalid_signature']);

        return new InvalidRequestException($result, [new Phrase('Invalid signature')]);
    }

    /**
     * @inheritDoc
     */
    public function validateForCsrf(RequestInterface $request): ?bool
    {
        /** @var HttpRequest $request */
        return $this->verifier->isValid(
            (string)$request->getHeader('X-Pocket-Signature'),
            (string)$request->getContent()
        );
    }
}

The shared secret was stored with bin/magento config:set and the Admin field type obscure with the backend model Magento\Config\Model\Config\Backend\Encrypted (the system.xml is in the next section). The database holds only the ciphertext. With a placeholder secret the seven cases were:

S='<shared-secret>'; B='{"order_number":"PC-100023","total":38.7}'; T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$B" | openssl dgst -sha256 -hmac "$S" -hex | sed 's/^.* //')
curl -s -w ' -> HTTP %{http_code}\n' -X POST http://localhost:8082/pcsec/callback/receive \
  -H 'Content-Type: application/json' -H "X-Pocket-Signature: t=$T,v1=$SIG" -d "$B"
valid signature              {"status":"accepted"} -> HTTP 202
wrong secret                 {"error":"invalid_signature"} -> HTTP 401
timestamp one hour old       {"error":"invalid_signature"} -> HTTP 401
body changed after signing   {"error":"invalid_signature"} -> HTTP 401
header missing               {"error":"invalid_signature"} -> HTTP 401
valid signature, bad payload {"error":"bad_payload"} -> HTTP 400
GET                          HTTP 401

and var/log/system.log contained main.INFO: PocketCoffee order paid {"order":"PC-100023","total":38.7} for the accepted request only. A GET is answered by 401 and not 404 because the custom validation runs before the method check. Webhooks of Adobe Commerce itself (the out-of-process extensibility webhooks) follow the same principle with their own signing mechanism (see out-of-process extensibility).

Authorization

Magento authorizes by configuration first and in code second.

  • Pages. A controller in Controller/ is a storefront page for guests and customers; a controller in Controller/Adminhtml/ is an Admin page, and it must extend Magento\Backend\App\Action and declare public const ADMIN_RESOURCE = 'Vendor_Module::resource' so that only roles with that ACL resource can open it.

  • REST and SOAP. Each route in webapi.xml lists resources: anonymous for guests, self for the authenticated customer (and with <parameter name="customerId" force="true">%customer_id%</parameter> the identifier comes from the token and the client cannot override it), or one or more Module_Name::resource ACL resources for administrators and integrations.

  • UI components. Add aclResource to a component or its data provider. Any UI component can be rendered directly through mui/render, so a grid that exposes sensitive data without aclResource is reachable without going through its controller.

  • GraphQL. The resolver’s $context->getExtensionAttributes()->getIsCustomer() tells whether the caller is a customer.

  • Ownership. "This is the customer’s own order" cannot be expressed in configuration; the code compares UserContextInterface::getUserId() with the owner of the entity and throws AuthorizationException otherwise. The check belongs in the service, not only in the controller, because blocks and UI component data providers are rendered by other entry points too.

The module declares the ACL resources and three REST routes, one for each level:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd">
    <acl>
        <resources>
            <resource id="Magento_Backend::admin">
                <resource id="Magento_Backend::stores">
                    <resource id="Magento_Backend::stores_settings">
                        <resource id="Magento_Config::config">
                            <resource id="PocketCoffee_Security::config" title="Pocket Coffee Security" sortOrder="90"/>
                        </resource>
                    </resource>
                </resource>
                <resource id="PocketCoffee_Security::report" title="Pocket Coffee Callback Report" sortOrder="95"/>
            </resource>
        </resources>
    </acl>
</config>
<?xml version="1.0"?>
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
    <route url="/V1/pocketcoffee-security/club/info" method="GET">
        <service class="PocketCoffee\Security\Api\ClubStatusInterface" method="getPublicInfo"/>
        <resources>
            <resource ref="anonymous"/>
        </resources>
    </route>
    <route url="/V1/pocketcoffee-security/club/me" method="GET">
        <service class="PocketCoffee\Security\Api\ClubStatusInterface" method="getMine"/>
        <resources>
            <resource ref="self"/>
        </resources>
        <data>
            <parameter name="customerId" force="true">%customer_id%</parameter>
        </data>
    </route>
    <route url="/V1/pocketcoffee-security/club/report" method="GET">
        <service class="PocketCoffee\Security\Api\ClubStatusInterface" method="getReport"/>
        <resources>
            <resource ref="PocketCoffee_Security::report"/>
        </resources>
    </route>
</routes>

and the service implements the explicit check for the self route (the class is also the example of constructor property promotion with readonly, which PHP 8.5 and the Magento 2.4.9 code base use):

<?php

declare(strict_types=1);

namespace PocketCoffee\Security\Model;

use Magento\Authorization\Model\UserContextInterface;
use Magento\Framework\Exception\AuthorizationException;
use Magento\Framework\Phrase;
use PocketCoffee\Security\Api\ClubStatusInterface;

/**
 * Coffee Club status service.
 */
class ClubStatus implements ClubStatusInterface
{
    /**
     * @param UserContextInterface $userContext
     */
    public function __construct(
        private readonly UserContextInterface $userContext
    ) {
    }

    /**
     * @inheritDoc
     */
    public function getPublicInfo(): string
    {
        return 'Coffee Club: 10% off beans, free shipping above 40 EUR';
    }

    /**
     * @inheritDoc
     */
    public function getMine(int $customerId): string
    {
        // Defence in depth: the framework already forced $customerId from the token, but an explicit
        // check keeps the service safe if it is ever called from another entry point.
        if ($this->userContext->getUserType() !== UserContextInterface::USER_TYPE_CUSTOMER
            || (int)$this->userContext->getUserId() !== $customerId
        ) {
            throw new AuthorizationException(new Phrase('The current customer is not authorized.'));
        }

        return sprintf('Customer %d is a Coffee Club member', $customerId);
    }

    /**
     * @inheritDoc
     */
    public function getReport(): string
    {
        return 'Callback report: 0 rejected, 0 accepted';
    }
}

The routes behave as follows on the local store (the admin token comes from the integration token endpoint with the local test account):

GET /rest/V1/pocketcoffee-security/club/info                         anonymous resource     200
GET /rest/V1/pocketcoffee-security/club/me      without a token      "self" resource        401
GET /rest/V1/pocketcoffee-security/club/report  without a token      ACL resource           401
GET /rest/V1/pocketcoffee-security/club/report  invalid bearer token ACL resource           401
GET /rest/V1/pocketcoffee-security/club/report  admin bearer token   ACL resource           200
GET /rest/V1/pocketcoffee-security/club/me      admin bearer token   "self" resource        400 ("customerId" is required)

The last line is the point of the forced parameter: an administrator token cannot call a self route, because there is no customer to fill the parameter in. The guest and administrator cases, and the admin page (which redirects to the login page without a session), were exercised; the success case of self needs a customer token and a customer account, and it was not run, nor was an Admin role that lacks the resource (the page shows the code and the configuration for them).

The 401 responses of the local store contain a stack trace in the JSON trace field because the store runs in developer mode; the trace is only added in developer mode, which is one more reason to run production mode on every internet-facing environment.

Sensitive information and encrypted configuration

Two rules cover data at rest.

  • Do not expose more than the caller may read. A generic findById() service that returns the whole customer record makes it easy to leak fields that need a stricter permission through REST, GraphQL, blocks and UI components alike. The fix is operation-specific DTOs and case-specific services (findById() and findPersonalDataById()), each behind the ACL resource that matches its data.

  • Hash what is only compared, encrypt what is read back. Magento\Framework\Encryption\EncryptorInterface::getHash($value, true) produces a salted hash and isValidHash() compares; encrypt() and decrypt() handle the encryption key, the cipher and key rotation. The local store’s output:

hash format  : <hash>:5Lio1Cq62Nm6gT36PvN4NGL7DfRfzn9R:3_32_2_67108864
valid hash   : true
invalid hash : false
cipher format: 0:3:<ciphertext>
decrypted    : ada@example.test

The hash string is hash:salt:version. The version 3_32_2_67108864 means hash version 3 (HASH_VERSION_ARGON2ID13_AGNOSTIC, Argon2id) followed by the seed length (32), the operations limit (2) and the memory limit (67,108,864 bytes) that were used, so that the parameters can change in the future without invalidating stored hashes; isValidHash() also accepts older MD5 and SHA-256 hashes. Calling getHash($value) without the salt argument returns an unsalted SHA-256 hash, which is not suitable for passwords or tokens that can be guessed. The cipher string is key-version:cipher:base64-ciphertext; cipher 3 is ChaCha20-Poly1305 (CIPHER_AEAD_CHACHA20POLY1305) and key version 0 is the first key listed in crypt/key of env.php.

A configuration value that is a secret needs three declarations: the field type obscure with the encrypted backend model in system.xml, a sensitive entry for Magento\Config\Model\Config\TypePool in di.xml (so that config:show and the dumps mask it and config:sensitive:set accepts it), and a decrypt call where it is read. The environment entry marks a value that differs per environment and must not be committed with the configuration:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
    <system>
        <section id="pocketcoffee_security" translate="label" type="text" sortOrder="900" showInDefault="1">
            <label>Pocket Coffee Security</label>
            <tab>service</tab>
            <resource>PocketCoffee_Security::config</resource>
            <group id="callback" translate="label" type="text" sortOrder="10" showInDefault="1">
                <label>Payment callback</label>
                <field id="secret" translate="label comment" type="obscure" sortOrder="10" showInDefault="1">
                    <label>Shared secret</label>
                    <comment>Used to verify the HMAC signature of the provider notification.</comment>
                    <backend_model>Magento\Config\Model\Config\Backend\Encrypted</backend_model>
                </field>
                <field id="tolerance_seconds" translate="label" type="text" sortOrder="20" showInDefault="1">
                    <label>Timestamp tolerance (seconds)</label>
                    <validate>validate-digits validate-greater-than-zero</validate>
                </field>
            </group>
        </section>
    </system>
</config>
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <preference for="PocketCoffee\Security\Api\ClubStatusInterface" type="PocketCoffee\Security\Model\ClubStatus"/>
    <type name="Magento\Config\Model\Config\TypePool">
        <arguments>
            <argument name="sensitive" xsi:type="array">
                <item name="pocketcoffee_security/callback/secret" xsi:type="string">1</item>
            </argument>
            <argument name="environment" xsi:type="array">
                <item name="pocketcoffee_security/callback/tolerance_seconds" xsi:type="string">1</item>
            </argument>
        </arguments>
    </type>
    <type name="Magento\Framework\Console\CommandListInterface">
        <arguments>
            <argument name="commands" xsi:type="array">
                <item name="pocketcoffee_security_demo" xsi:type="object">PocketCoffee\Security\Console\DemoCommand</item>
            </argument>
        </arguments>
    </type>
</config>

Runs on the local store:

$ bin/magento config:set pocketcoffee_security/callback/secret '<placeholder>'
Value was saved.
$ bin/magento config:show pocketcoffee_security/callback/secret
******
core_config_data.value: 0:3:bcfiS5qwfciH4ZhCWty6ghUdPqBVcjFUqsjya0i+qIJND1HqxTta9GhMW9uYiDvP2I9hxjiy

config:sensitive:set writes a sensitive value to app/etc/env.php so that it is not in the database at all (the command applied the encrypted backend model: the value in the file started with 0:3:), and the callback accepted a request signed with the value set that way; the file then has to be protected like the encryption key (env.php must not be readable by other users and must never be committed). When the encryption key changes (bin/magento encryption:key:change), values in core_config_data and in payment tables must be re-encrypted with bin/magento encryption:data:re-encrypt core_config_data sales_order_payment. Custom tables with encrypted columns add a re-encryptor with a virtual type of Magento\EncryptionKey\Model\Data\ReEncryptorList\ReEncryptor\SimpleHandler that names the table, the primary key and the columns; bin/magento encryption:data:list-re-encryptors lists what is registered. On the local store it printed:

core_config_data     Re-encrypts 'value' column in the 'core_config_data' DB table.
sales_order_payment  Re-encrypts 'cc_number_enc' column in the 'sales_order_payment' DB table.

Temporary access and signed tokens

An e-mail link that must work without a session (finish a Coffee Club sign-up) needs a self-signed token with an expiry, which needs no storage. The framework offers Magento\Framework\Jwt\JwtManagerInterface and a JwkFactory for the keys. The console command of the module signs a token with HS256, reads it back, and then shows the two failures a token reader has to handle:

<?php

declare(strict_types=1);

namespace PocketCoffee\Security\Console;

use Magento\Framework\Encryption\EncryptorInterface;
use Magento\Framework\Escaper;
use Magento\Framework\Jwt\Claim\ExpirationTime;
use Magento\Framework\Jwt\Claim\Subject;
use Magento\Framework\Jwt\Header\Algorithm;
use Magento\Framework\Jwt\JwkFactory;
use Magento\Framework\Jwt\Jws\Jws;
use Magento\Framework\Jwt\Jws\JwsHeader;
use Magento\Framework\Jwt\Jws\JwsSignatureJwks;
use Magento\Framework\Jwt\JwtManagerInterface;
use Magento\Framework\Jwt\Payload\ClaimsPayload;
use Magento\Framework\Jwt\Payload\ClaimsPayloadInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

/**
 * Prints the output of the framework security helpers used by the Pocket Coffee examples.
 */
class DemoCommand extends Command
{
    /**
     * @param EncryptorInterface $encryptor
     * @param Escaper $escaper
     * @param JwtManagerInterface $jwtManager
     * @param JwkFactory $jwkFactory
     */
    public function __construct(
        private readonly EncryptorInterface $encryptor,
        private readonly Escaper $escaper,
        private readonly JwtManagerInterface $jwtManager,
        private readonly JwkFactory $jwkFactory
    ) {
        parent::__construct('pocketcoffee:security:demo');
    }

    /**
     * @inheritDoc
     */
    protected function configure(): void
    {
        $this->setDescription('Shows hashing, encryption, escaping and signed tokens');
    }

    /**
     * @inheritDoc
     */
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        // 1. One-way hash with salt: compare with isValidHash(), never decrypt.
        $hash = $this->encryptor->getHash('club-invite-8841', true);
        $output->writeln('hash format  : ' . preg_replace('/^[^:]+/', '<hash>', $hash));
        $valid = $this->encryptor->isValidHash('club-invite-8841', $hash);
        $invalid = $this->encryptor->isValidHash('club-invite-0000', $hash);
        $output->writeln('valid hash   : ' . var_export($valid, true));
        $output->writeln('invalid hash : ' . var_export($invalid, true));

        // 2. Reversible encryption for data that must be read back later.
        $cipher = $this->encryptor->encrypt('ada@example.test');
        $output->writeln('cipher format: ' . preg_replace('/:[^:]+$/', ':<ciphertext>', $cipher));
        $output->writeln('decrypted    : ' . $this->encryptor->decrypt($cipher));

        // 3. Context-aware escaping.
        $evil = '<script>alert(1)</script>"\'';
        $output->writeln('escapeHtml   : ' . $this->escaper->escapeHtml($evil));
        $output->writeln('escapeHtmlAttr: ' . $this->escaper->escapeHtmlAttr($evil));
        $output->writeln('escapeJs     : ' . $this->escaper->escapeJs($evil));
        $output->writeln('escapeUrl ok : ' . $this->escaper->escapeUrl('https://example.test/a b'));
        $output->writeln('escapeUrl js : ' . var_export($this->escaper->escapeUrl('javascript:alert(1)'), true));

        // 4. Self-signed, expiring token for an e-mail link (JWS with HS256).
        $secret = random_bytes(2048);
        $settings = new JwsSignatureJwks($this->jwkFactory->createHs256($secret));
        $jws = new Jws(
            [new JwsHeader([new Algorithm('HS256')])],
            new ClaimsPayload([
                new Subject('ada@example.test'),
                new ExpirationTime(new \DateTimeImmutable('+7 days')),
            ]),
            null
        );
        $token = $this->jwtManager->create($jws, $settings);
        $output->writeln('token parts  : ' . count(explode('.', $token)));
        $claims = $this->read($token, $settings);
        $output->writeln('token subject: ' . $claims['sub']->getValue());

        $expired = new Jws(
            [new JwsHeader([new Algorithm('HS256')])],
            new ClaimsPayload([
                new Subject('ada@example.test'),
                new ExpirationTime(new \DateTimeImmutable('-1 minute')),
            ]),
            null
        );
        $oldToken = $this->jwtManager->create($expired, $settings);
        try {
            $this->read($oldToken, $settings);
            $output->writeln('expired token: accepted (BUG)');
        } catch (\Throwable $e) {
            $output->writeln('expired token: rejected (' . get_class($e) . ')');
        }

        $other = new JwsSignatureJwks($this->jwkFactory->createHs256(random_bytes(2048)));
        try {
            $this->read($token, $other);
            $output->writeln('wrong key    : accepted (BUG)');
        } catch (\Throwable $e) {
            $output->writeln('wrong key    : rejected (' . get_class($e) . ')');
        }

        return Command::SUCCESS;
    }

    /**
     * Read a token and fail when the signature or the expiration is not valid.
     *
     * @param string $token
     * @param JwsSignatureJwks $settings
     * @return array
     * @throws \RuntimeException
     */
    private function read(string $token, JwsSignatureJwks $settings): array
    {
        $jwt = $this->jwtManager->read($token, [$settings]);
        $payload = $jwt->getPayload();
        if (!$payload instanceof ClaimsPayloadInterface) {
            throw new \RuntimeException('Invalid token');
        }
        $claims = $payload->getClaims();
        if (!isset($claims['exp']) || (int)$claims['exp']->getValue() <= time()) {
            throw new \RuntimeException('Token expired');
        }

        return $claims;
    }
}

Output:

token parts  : 3
token subject: ada@example.test
expired token: rejected (RuntimeException)
wrong key    : rejected (Magento\Framework\Jwt\Exception\MalformedTokenException)

Two findings from running it matter. First, JwtManagerInterface::read() verifies the signature but does not check the exp claim: the expired token was read without an exception, and the RuntimeException came from the explicit check in read() of the command. A caller that skips that check accepts expired tokens. Second, JwkFactory::createHs256() throws "Shared secret key must be at least 2048 bits" for a 256-byte key and accepts a 2048-byte key: the implementation compares the string length (bytes) with 2048, so the message and the check disagree. For an HMAC secret, derive a long random key and store it like any other secret rather than relying on the message.

Mass assignment, SQL and files

  • Mass assignment. Controller code such as $model->setData($request->getPostValue()); $model->save(); lets a client set any column (an id, an is_admin flag). Use service contracts with data interfaces, which list the writable fields, and do not save request data wholesale.

  • SQL. Build queries with the resource model and the adapter’s prepared statements, and use sanitising methods for dynamic parts; building a query by concatenating $_POST is the pattern the coding standard flags. Every foreign key and every column in a WHERE clause needs an index, and tables need primary keys.

  • Files. The best upload is the one you do not store: read the CSV of SKUs and quantities and discard it. When a file must be stored, use random names without extension, a directory such as var/ that is not served by the web server, database records for ownership checks, and Magento\Framework\Filesystem instead of native PHP filesystem functions (they also fail with remote storage such as Amazon S3).

  • Server-side request forgery (SSRF). Do not fetch URLs entered by users. If you must, accept only the path or limit the host to a list of trusted hosts and the scheme to http and https (never phar:// or tcp://), and never put the response in an error message.

  • Denial of service. Cap the number of entities a request may create, update or return, and put a CAPTCHA in front of heavy operations and calls to paid external services; 2.4.9 does this for GraphQL with the alias and query length limits.

  • Brute force. Protect every interface to a guessable secret (HTML, REST, GraphQL) with reCAPTCHA, make machine-readable secrets at least 16 characters (32 preferred) from a secure random source, and add throttling at the CDN or load balancer.

Functions that are forbidden

The documentation lists PHP functions that lead to code execution or weak cryptography and the coding standard rejects them: eval, unserialize and serialize (use Magento\Framework\Serialize\SerializerInterface), md5 and sha1 for anything sensitive, srand, mt_srand and mt_rand (use random_int()), include with a user-controlled path, and the class ArrayObject, whose unserialize() is exploitable. Low-level functionality such as the curl_* functions is discouraged in favour of Magento\Framework\HTTP\Adapter\Curl, superglobals should be read through Magento\Framework\HTTP\PhpEnvironment\Request, and file_get_contents() should be replaced by the filesystem API. A deliberately bad class,

<?php

declare(strict_types=1);

namespace PocketCoffee\Bad;

class Bad
{
    private $connection;

    public function run(string $input, string $url): void
    {
        $om = \Magento\Framework\App\ObjectManager::getInstance();
        eval($input);
        $data = unserialize($_GET['d']);
        $hash = md5($input);
        $id = mt_rand();
        $row = $this->connection->query("SELECT * FROM sales_order WHERE increment_id = '" . $_POST['id'] . "'");
        $handle = curl_init($url);
        $body = file_get_contents($url);
        $sha = sha1($input);
    }
}

was checked with vendor/bin/phpcs -q --standard=Magento2 (PHP_CodeSniffer 3.13.6 with the Magento coding standard of Mage-OS 3.6.0):

FILE: /tmp/bad/Bad.php
 14 | ERROR   | The use of function eval() is forbidden
 14 | ERROR   | Use of eval() is discouraged
 15 | ERROR   | The use of function unserialize() is forbidden; use \Magento\Framework\Serialize\SerializerInterface::unserialize() instead
 15 | ERROR   | Direct use of $_GET Superglobal detected.
 16 | ERROR   | The use of function md5() is forbidden; use improved hash functions (SHA-256, SHA-512 etc.)() instead
 17 | ERROR   | The use of function mt_rand() is forbidden; use random_int() instead
 18 | WARNING | Possible raw SQL statement "SELECT * FROM sales_order WHERE increment_id = '" detected.
 18 | ERROR   | Direct use of $_POST Superglobal detected.
 19 | WARNING | The use of function curl_init() is discouraged
 20 | WARNING | The use of function file_get_contents() is discouraged

Two of the lines of the class passed silently, and that is worth knowing: the ObjectManager::getInstance() call and sha1() were not reported by the standard in this version, so a code review still has to look for them (the page on dependency injection explains why the ObjectManager must not be used directly). The module itself, with severity 1 and the php and phtml extensions, reported no errors and no warnings.

Two small admin routes

The module’s Admin page needs only the menu entry and the route, and is protected by ADMIN_RESOURCE:

<?php

declare(strict_types=1);

namespace PocketCoffee\Security\Controller\Adminhtml\Report;

use Magento\Backend\App\Action;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\View\Result\Page;
use Magento\Framework\View\Result\PageFactory;

/**
 * Admin page that needs the ACL resource PocketCoffee_Security::report.
 */
class Index extends Action implements HttpGetActionInterface
{
    public const ADMIN_RESOURCE = 'PocketCoffee_Security::report';

    /**
     * @param Action\Context $context
     * @param PageFactory $pageFactory
     */
    public function __construct(Action\Context $context, private readonly PageFactory $pageFactory)
    {
        parent::__construct($context);
    }

    /**
     * @inheritDoc
     */
    public function execute(): Page
    {
        $page = $this->pageFactory->create();
        $page->getConfig()->getTitle()->prepend(__('Pocket Coffee Callback Report'));

        return $page;
    }
}

An unauthenticated request to the Admin route answered 200 with the login page (the URL included the Admin URI, and the secret key requirement redirects anything else to the dashboard of a logged-in user). The unauthorized-role case needs an Admin user with a limited role and was not run.

Scanning and monitoring

  • Security Scan Tool. A free service in the Commerce account that scans Adobe Commerce and Magento Open Source sites for known risks and malware (more than 21,000 tests), on a weekly (recommended) or daily schedule, with an e-mail report and a risk score. Setting it up needs a Commerce or Magento account, the acceptance of its terms and proof of domain ownership by adding a confirmation code to the Design Configuration HTML Head (or head.html of an Edge Delivery storefront or the template of a PWA storefront). Failures that are false positives can be ignored from the report; they leave the risk score. No account was created for this page, so the tool was not run.

  • Static analysis. vendor/bin/phpcs --standard=Magento2 includes the security sniffs shown above and is the same command that the Marketplace Extension Quality Program runs (see testing, coding standards and the Marketplace).

  • Dependency audit. composer audit checks the installed packages against published advisories. On the local Mage-OS 3.6.0 store it reported "No security vulnerability advisories found" and seven abandoned packages (doctrine/annotations, four laminas/laminas-* libraries, and two mage-os/module-* packages). Mage-OS 3.6.0 also ships mage-os/composer-dependency-version-audit-plugin.

  • Patch status. vendor/bin/patch-status (Commerce Version Tool) and, on Adobe Commerce, vendor/bin/magento-patches status.

  • security.txt. The module Magento_Securitytxt (enabled on the local store) can publish a security.txt with a contact for vulnerability reports. It is disabled by default and is configured per website; the local store answered 404 for /.well-known/security.txt and /security.txt.

  • Adobe Observation and logging. On Adobe Commerce on cloud infrastructure, the Observation for Adobe Commerce tool has a Security tab and a WAF tab, and the Action Logs report records Admin actions with the user and the IP address.

Incident response

If a shop is compromised, or if a critical vulnerability was exploitable before the patch was applied, the guidance of Adobe’s incident page and of the APSB26-146 bulletin combine into this routine.

  1. Preserve evidence: copy the logs (web server, PHP, var/log, database) and a disk image or snapshot before changing anything. Determine the scope: whether payment data or customer data was accessed, how long the shop was exposed and whether data was encrypted.

  2. Close the entry point: apply the hotfix or patch (verify with the status tools), and block or remove the vulnerable endpoint if the patch is not available yet.

  3. Rebuild from known-clean code where there is any doubt. Compare against a clean copy of the release, reinstall extensions from the vendor or version control and remove unneeded files. Malware may hide in unusual places.

  4. Rotate every secret that the attacker could have read, in this order of impact: the encryption key (bin/magento encryption:key:change, then bin/magento encryption:data:re-encrypt core_config_data sales_order_payment and the custom re-encryptors), all Admin passwords, integration tokens and OAuth secrets (revoke and re-create them), payment gateway keys, the database credentials, the SSH and deployment keys, and the secrets of each module (such as the pocketcoffee_security/callback/secret of this page). The encryption key rotation is the lesson of CosmicSting; APSB26-146 lists the same set.

  5. Audit Admin users and roles, the Action Logs, Content > Design > Configuration (the HTML Head and the absolute header and footer, where skimmers are injected), CMS blocks and pages, cron jobs, and recently modified files.

  6. Monitor closely afterwards: many sites are compromised again within hours. Enable file integrity monitoring and CSP report collection.

  7. Follow the legal and card-scheme duties that apply: payment processors and data protection law can require notification.

What was run and what was not

On the local Mage-OS 3.6.0 store (PHP 8.5.12, MariaDB 11.8.9, OpenSearch 3.9.0, Valkey 9.1.3, Apache 2.4, developer mode, Magento_TwoFactorAuth disabled) the following ran and produced the results quoted above: installation of the PocketCoffee_Security module (module:enable, setup:upgrade, cache:flush); XML validation of every file against the framework XSDs; php -l on all PHP and phtml files; phpcs --standard=Magento2 on the module (clean) and on the deliberately bad class and template; the signed callback with seven cases; the form key cases; the three REST routes with no token, an invalid token and an administrator token; the page’s CSP headers, nonce and whitelisted hosts; the escaping demonstration over HTTP and in the console; the console command for hashing, encryption and the JWT; config:set and config:sensitive:set on an encrypted field; encryption:data:list-re-encryptors; the exposure probe; and composer audit.

Not run: the Security Scan Tool (needs a Commerce account); the Commerce Version Tool and the Quality Patches Tool (they ship with Adobe Commerce patches and need Adobe Composer keys); Adobe Commerce-only features; Adobe Commerce on cloud infrastructure and ACCS (enforcement, Fastly WAF, Observation); the APSB bulletin pages on adobe.com (the figures were taken from NVD, the CISA catalog and Adobe’s knowledge-base pages, and the bulletin text of APSB25-94, APSB26-05 and APSB26-49 was not read directly); 2FA enrolment and login (the module is disabled on the local store); an Admin user with a restricted role; a customer token and the self route success; Varnish and a CDN or web application firewall; and production mode. Mage-OS 3.6.0 still ships the csp_whitelist.xsd and webapi.xsd files that Adobe’s 2.4.9 notes list as removed, so schema validation here cannot confirm the Adobe 2.4.9 behaviour for those two files.

References