Skip to content

Security

What Nebula changes about the admin's security posture, what it leaves to Magento, and the settings an operator should decide on before going live.

Signing in

Second factor. Nebula registers a passkey as a provider in Magento_TwoFactorAuth under the code nebula_passkey, and sets twofactorauth/general/force_providers to google,nebula_passkey. That list is cumulative, not a choice. Every administrator must configure both an authenticator app and a passkey before the dashboard opens. An administrator who already has Google Authenticator set up is still required to enrol a passkey, and because only one provider is then outstanding the choice screen is skipped and Magento sends them to its own tfa/tfa/requestconfig, which emails a configuration link. Set the field to a single provider code, google or nebula_passkey, to require only one. Clearing it forces nothing and sends the administrator to Magento's own provider-selection screen instead. See First sign-in.

Passwordless sign-in. A Sign in with Passkey button sits under the password form on the admin login screen, backed by discoverable credentials, so a registered administrator signs in with neither username nor password.

A passkey can be both factors. With A passkey satisfies two-factor authentication set to Yes, a sign-in with a passkey whose authenticator verified the user is not challenged again. The browser verified the person and the device, and the credential is bound to the admin's origin. Set it to No to require the second factor after every passkey sign-in.

Nebula's own two-step enforcement stands down whenever Magento_TwoFactorAuth is enabled, which it is on a stock install. Two modules enforcing a second factor on one sign-in route would loop. Nebula enforces on its own only when the core module is disabled and the administrator has a confirmed authenticator secret.

Relying party. Leave Relying Party ID under Stores › Configuration › Security › Nebula Passkey empty unless you have a reason not to. Empty means the host of the admin base URL, which is the right answer for almost every install. An override may only generalise that host to a parent domain, so an admin on admin.example.com may be overridden to example.com and to nothing else. Anything else is refused with The configured passkey relying party "…" is not the host "…" or a parent domain of it.

An override is validated late

The refusal is raised when a passkey ceremony runs, not when the value is saved. The configuration form does not validate it and bin/magento nebula:doctor does not check it, so the first sign of a bad override is a failed enrolment or a failed sign-in. Test an override before administrators enrol.

The admin relying party is nebula_passkey/general/rp_id and the storefront one is nebula_passkey/frontend/rp_id. They are separate settings and separate credential scopes on purpose, so a customer credential can never assert into the admin. Both make the base URL host part of your authentication material. A domain change, or a staging clone served on a different host, invalidates every enrolled passkey. On a database copy served on another host, tell the administrators who will use it to sign in with their authenticator app and enrol a fresh passkey there. Their production passkeys are untouched. Where an administrator has no working authenticator app on the copy, clear their second factor on the copy alone with bin/magento security:tfa:reset <admin username> google and bin/magento security:tfa:reset <admin username> nebula_passkey, then let them set both up again. On a stock install nebula:passkey:reset-two-step is not the command for this, because it clears Nebula's own two-step and leaves Magento_TwoFactorAuth's per-user configuration in place. See CLI. Never point a copy's Relying Party ID at the production host: the value must be the copy's own admin host or a parent of it, and anything else is refused.

Two admin endpoints answer before a session exists. Passkey sign-in runs before the administrator is authenticated, so nebulapasskey/auth/options and nebulapasskey/auth/verify are reachable with no admin session and are deliberately exempt from the form key. There is no form key to send yet. Both are POST-only, and auth/verify is the line where an unauthenticated request becomes an admin session. The storefront twins are passkey/passkey/options and passkey/passkey/verify. A reviewer should check the three compensating controls: rate limiting, described next; an empty credential list for an unknown username, which is the same answer a known account with no passkeys gets, so the endpoint does not confirm whether a username exists; and a re-check that the account may authenticate, run against the user auth/verify is about to seat rather than one derived earlier.

Rate limiting. Passkey attempts are counted per client address, in two counters:

Counter Limit Window
Challenge requests to auth/options 5 60 seconds
Failed verifications 3 900 seconds

The windows are fixed rather than sliding. A window opens on the first counted event and the counter expires with it, so a block clears on its own after at most one minute or fifteen minutes. A successful verification clears the failure counter immediately. The failure counter is shared by the passkey assertion and the two-step code path, so wrong authenticator codes and wrong recovery codes count toward the same three. Admin and storefront counters are separate on purpose, so a storefront passkey sign-in cannot clear an administrator's failure count.

The address is REMOTE_ADDR, read through Magento\Framework\HTTP\PhpEnvironment\RemoteAddress, not the client-supplied X-Forwarded-For.

Behind a proxy, configure the forwarded header

Forwarded headers are trusted only for the list an operator configures as that class's alternativeHeaders in di.xml. Without that, every request arrives from the proxy's address and one office shares one counter, so one person's failures lock out everybody. Requests with no readable address share a single unknown bucket.

Counters live in the nebula_passkey_throttle cache type. bin/magento cache:clean nebula_passkey_throttle lifts every active passkey lockout at once, and disabling that cache type stops the counters persisting, which removes the limit. See ACL and events.

Recovery. Each administrator holds recovery codes for their authenticator app. A refused sign-in is not always a lost factor: check the rate limits above first, because a throttled two-step attempt returns the same message as a wrong code and the screen will not say which it was. If waiting out the window does not help, an operator with shell access can reset the account:

bin/magento nebula:passkey:reset-two-step --user=<admin username>
bin/magento security:tfa:reset <admin username> nebula_passkey
bin/magento security:tfa:reset <admin username> google

Authorization

Every definition carries an acl. The access control is default-deny: a grid or form definition without an acl key is refused for every administrator, including the one with all resources. The quick-edit drawer has its own editable.acl, never defaulted from the grid's, because reading and writing are different resources in core too.

Almost every controller declares a specific resource. Nebula's admin controllers set ADMIN_RESOURCE to a named resource, or derive one from the definition being acted on. Nine sit on the base Magento_Backend::admin resource that every administrator holds, in two groups and for two different reasons.

The first group is per-administrator preferences, where there is no other user's data to reach. Saved grid views are nebula/gridView/save, nebula/gridView/delete and nebula/gridView/setDefault. Each row is stamped with the current admin user id, and ownership is enforced in Nebula\Grid\Model\GridViewManager, which answers View not found. for a missing view and for another administrator's view alike, so ids cannot be probed. Pinned menu items are nebula/menupin/index, nebula/menupin/reorder and nebulamenu/pin/index, and pinned configuration sections are nebula/config/pin. All four write into the signed-in administrator's own admin_user.extra.

The second group is the two-step challenge pair, nebulapasskey/totp/challenge and nebulapasskey/totp/verify. These sit on the base resource deliberately. The obvious choice, Nebula_Passkey::two_step_manage, would be a lockout, because a role change can take that resource away and leave the administrator held at a challenge page they no longer have permission to open. The page that lets somebody back in must not sit behind a privilege that can be removed. The challenge guards itself instead, by refusing anybody who is not mid-verification.

Any authenticated administrator can call all nine regardless of role. That is what the base resource means, so the safety of each depends on its own scoping rather than on ACL.

What each front name checks. Nebula ships nine admin front names. A reviewer who needs to firewall, log or pen-test them needs the resource behind each.

Endpoints How they are authorised
nebula/grid/data, nebula/grid/export, nebula/form/save, nebula/form/validate, nebula/gridedit/panel, nebula/gridedit/save The acl in the resolved definition. Default-deny; see Grid reference and Saving and validation.
nebula/selection/search, nebula/selection/resolve, nebula/grid/productsearch getAclResource() on the registered selection source. An unregistered source is refused.
nebula/category/reorderchildren Magento_Catalog::categories
nebula/gridView/*, nebula/menupin/*, nebula/config/pin, nebulamenu/pin/index, nebulapasskey/totp/challenge, nebulapasskey/totp/verify Magento_Backend::admin, for the reasons above.
nebulamedia/browser/*, nebulamedia/gallery/index Magento_Cms::media_gallery
nebulasystem/variable/*, nebuladirective/* Magento_Variable::variable
nebulasystem/notification/* Magento_AdminNotification::show_list, ::mark_as_read, ::adminnotification_remove
nebulasystem/export/filters Magento_ImportExport::export
nebulaemail/* Magento_Email::template
nebula_landing/* Nebula_LandingPage::manage
nebulapasskey/passkey/*, nebulapasskey/revoke/*, nebulapasskey/totp/* other than the challenge pair Nebula_Passkey::passkey_manage, ::revoke_others, ::two_step_manage
nebulapasskey/tfa/* Magento_TwoFactorAuth::tfa
nebulapasskey/auth/options, nebulapasskey/auth/verify Nothing. They run before a session exists; see Signing in.
nebula/merchandiser/* Magento_Catalog::categories
nebula/ordercreate/* Magento_Sales::create
nebula/customerloginas/end, nebula/customerassistance/set Magento_LoginAsCustomer::login, ::allow_shopping_assistance
nebula/customerpassword/set Nebula_Theme::customer_set_password
nebula/agreement/*, nebula/rating/*, nebula/taxrate/*, nebula/taxrule/* Core's own resource, inherited by extending the core controller these replace.

The remaining front name is nebula_theme_switcher, for the admin theme switcher.

Role templates. Nebula_Roles ships ten templates: Developer, Store Owner, Store Manager, Merchandiser, Content and Marketing, Sales Agent, Sales Supervisor, Customer Service, Finance, Analyst. The picker on the role form resolves each template against the install's own ACL tree, shows the resources it would grant as a diff before Save, and reports any resource in the template that this install does not have. A template is a starting point; review the diff.

Field-level product permissions. Two ACL resources under Catalog › Products gate individual attributes:

Resource Attributes
Nebula_Roles::product_price price, special_price, special_from_date, special_to_date, cost, msrp
Nebula_Roles::product_inventory qty, is_in_stock

The product form omits the fields for a role without the resource. A plugin then refuses a save that changes a protected attribute. It compares values rather than stripping keys, so a payload that does not carry price is not a change and is not refused. Extend the map through the protectedAttributes DI argument; see Extension points.

Field-level protection covers people, not the server

The plugin is declared in the adminhtml and webapi_rest areas only, never globally. It covers the admin product form, through the product initialization helper, because the core Save controller persists through the model rather than the repository, and it covers ProductRepositoryInterface in those two areas, so an authenticated REST write is checked. It does not cover anything in the global or crontab areas: bin/magento commands, CSV and CLI imports, migrations, data patches, cron. There is no signed-in administrator there, so Magento's authorization would answer no to everything and refuse every server-side product write. A unit test keeps the plugin out of the global di.xml for exactly that reason. A merchant who needs price protection against imports must control who can run imports.

A script that sets the area to adminhtml puts itself inside the gate

The area is what decides this, not the fact that something runs on the server. A provisioning script, a fixture or a bin/magento command that calls State::setAreaCode('adminhtml'), which is common advice for making admin scoped configuration resolve, is inside the plugin's area with no signed in administrator, so every protected attribute counts as unwritable and a ProductRepositoryInterface::save() that sets a price fails with Not enough permissions to edit the product field "price". Nothing in that message says the cause is the area. The same script in the global area saves without any check. So leave a server side script in the global area, or persist through the model or the resource model, which no repository plugin sees. Magento core behaves the same way for Magento_Catalog::edit_product_design, and it binds its own plugin to webapi_rest and webapi_soap only.

Requests

Concern What Nebula does
CSRF Every authenticated state-changing action is a POST with the form key. Row actions declare method: "post", scripts use Nebula.postAction(), and mass actions post the selection. Two exceptions: the passkey sign-in endpoints under Signing in are exempt because they run before a form key exists, and nebula/config/pin mutates the administrator's own pinned sections without declaring POST-only.
Mass assignment Four save paths, two of which whitelist. The generic nebula/form/save intersects the payload with the fields the definition declares. Generated CRUD controllers carry a const whitelist. Core save controllers behave as core. Nebula's own entity controllers are mixed: landing page, email template, rating, tax rate and tax rule build the entity through explicit setters, so a key the form does not expose is never persisted, but nebulasystem/variable/save and nebula/agreement/save pass the POST straight to setData(), so any column of variable or checkout_agreement present in the body is written. Both are POST-with-form-key behind their controller's ADMIN_RESOURCE, and the resource model writes only real table columns. Grid inline edit and the quick-edit drawer drop posted keys that no editable column accounts for; see Grid reference.
Uploads Validation is core's, with one deliberate widening for webp and avif in the product gallery. See Uploads below.
Grid requests Filter and sort keys are whitelisted against declared columns. Page size is clamped to 200, export to 100,000, picker searches to 100, and a missing or non-positive size means the default.
Spreadsheet injection A CSV cell starting with =, +, -, @, a tab or a carriage return is prefixed with an apostrophe so a spreadsheet does not evaluate it.
Path traversal The media browser rejects .. segments and checks the resolved path stays under the media root before deleting.
DOM XSS Toasts and admin messages are written with textContent. Templates escape through $escaper; snippet templates are expected to do the same.
Option sources An option source resolves by alias through OptionSourceRegistry and FilterOptionSourceRegistry. An unknown alias with no backslash fails closed. A value containing a backslash does not: it falls through to ObjectManager::create() and is accepted if it implements OptionSourceInterface or merely exposes toOptionArray(). nebula:doctor does not flag it, because its class-name check covers grid collections only.
Definitions are code dataSource.repository, dataSource.model, editable.model, editable.resource and validation.custom are class names read straight out of the JSON and handed to the ObjectManager. Write access to a module's view/adminhtml/grid/, form/, snippet/ or component/ JSON, to var/nebula/, or to the compiled generated/nebula/definitions.php is therefore equivalent to instantiating a class of your choosing in the admin. Deploy pipelines and code review must treat definition files the way they treat PHP.
Customer passwords set by an administrator The dialog requires the administrator's own password, refuses a password weaker than the store's policy, ends every session of that customer, marks the password temporary so the customer must replace it, and signs the administrator out after three wrong password confirmations. The permission is Nebula_Theme::customer_set_password.

Uploads

The media browser's write endpoints are nebulamedia/browser/upload, trash, deletefile, deletedirectory, move, rename, renamedirectory, createdirectory, restore and savealttext. All are POST-only. contents and tree are the only reads. Every one of them, and the gallery screen itself, sits on the single resource Magento_Cms::media_gallery, inherited from the shared controller base.

Upload validation is Magento's, not Nebula's. The picker calls core's Magento\Cms\Model\Wysiwyg\Images\Storage::uploadFile(), so the accepted extensions are core's own image list for that storage. The file dialog's accept hint is read back from the same list, which is why the dialog can never offer a format the backend then refuses. Nebula adds nothing to it.

There is one place Nebula does widen a core allowlist. The product gallery accepts webp and avif in addition to core's jpg, jpeg, gif and png, but only when the install's image adapter is ImageMagick and Imagick::queryFormats() reports that specific format. The two are decided independently, so a box may get WebP and not AVIF, and on the default Gd2 adapter the plugin does nothing. Formats core already accepts take core's untouched path. A widened upload still runs core's catalog_product_image content validation through the image adapter, and any error at all falls back to core's own refusal.

Content Security Policy

Magento ships the admin CSP in report-only mode with script-src allowing 'self', inline scripts and eval. Nebula runs under that default.

Nebula needs eval because Alpine.js compiles the expression in every x- attribute at runtime. That is a policy-level permission a nonce cannot grant, and it is permanent: the CSP-safe build of Alpine removes the expression evaluator, and with it the authoring model Nebula is built on.

Most of Nebula's inline <script> and <style> elements are rendered through Magento's SecureHtmlRenderer, so they carry the request nonce. Four templates are not, and an inline style attribute cannot carry a nonce at all. That sets the boundary on what can be tightened:

Setting Path Value Effect
Enforce the admin policy csp/mode/admin/report_only 0 Violations are blocked, not just reported. Supported.
Keep eval csp/policies/admin/scripts/eval 1 (the default) Required. 0 breaks every Nebula page.
Keep inline scripts csp/policies/admin/scripts/inline 1 (the default) Required for now. 0 stops the four Nebula templates listed below, and any third-party template with an un-nonced inline script.
Keep inline styles csp/policies/admin/styles/inline 1 (the default) Required. Nebula lays out pages with inline style attributes in templates and with Alpine :style bindings. 0 breaks page layout.

Turning off the inline script policy breaks passkey sign-in

Four Nebula templates render a raw inline <script> with no nonce and stop running once 'unsafe-inline' is removed from the admin script policy:

  • The passkey sign-in button on /admin. Its inline block defines the Alpine component the button binds to, so the button goes dead and nobody signs in with a passkey. On an install where passkeys are the second factor, that is a lock-out risk.
  • The My Passkeys page, so no administrator can enrol, rename or revoke a credential.
  • Both admin theme switcher panels, which that module mounts on every admin page.

Until those move to SecureHtmlRenderer, treat csp/policies/admin/scripts/inline 0 as unsupported. To check it on your own install, enforce the policy on staging, open /admin and then the My Passkeys page with the browser console open, and look for a blocked inline script report on each.

bin/magento config:set csp/mode/admin/report_only 0
bin/magento cache:clean config

Hosts for external resources are declared the usual way, in a module's etc/adminhtml/csp_whitelist.xml. Test in enforce mode on staging with the browser console open; a report from Alpine's evaluator means eval was turned off, and any other report names the template to fix.

What Nebula leaves to Magento

Admin sessions, session lifetime, password policy for administrators, account lockout, the admin URL and secret keys, IP allow-listing, the REST and GraphQL APIs, and storefront security are Magento's, unchanged.

The one exception is the passkey module's storefront half, and it is not optional. Installing Nebula_Passkey changes the storefront immediately by overriding the core customer login form template and adding a passkey button beside it. There is no on/off setting: the frontend config group declares rp_id, rp_name and passkey_satisfies_2fa and nothing else. It adds passkeys and two-step for customers and changes nothing else about customer authentication.

Before going live

  • [ ] Relying Party ID left empty, or set to a parent of the admin host and proved by enrolling one passkey.
  • [ ] Every administrator has enrolled a second factor, and a break-glass administrator's recovery codes are stored offline.
  • [ ] The client address the rate limiter sees is the visitor's, not the proxy's.
  • [ ] Roles reviewed against the templates; nobody who does not need it holds product_price or product_inventory. Whoever can run an import or a bin/magento command bypasses both, so review that list too.
  • [ ] Custom definitions carry an acl, and bin/magento nebula:lint passes.
  • [ ] Definition JSON and generated/nebula/definitions.php are deployed and reviewed as code, not as content.
  • [ ] CSP tested in enforce mode on staging with every third-party extension installed, with the admin script and style inline policies left on.
  • [ ] bin/magento nebula:doctor reports OK.