The core/MFAArea extension adds one tick box to a single ACP form. That form is the ACP screen registered in applications/core/data/acpmenu.json as settings → mfa and labelled "Two Factor Authentication" — app=core&module=settings&controller=mfa — and the field is "Require two factor authentication when". Each MFAArea extension in the suite contributes one option to that list — "Logging into AdminCP", "Changing email address", "Managing stored cards", and so on. When an administrator ticks your option, the key is written into the security_questions_areas setting, and from that moment a member who reaches the part of your application you have protected is shown a two-factor challenge before they are allowed to continue.
The extension itself does not perform the challenge, does not know which page it protects, and is never asked whether the member should be challenged. It is a registration list for the ACP checkbox, nothing more. The challenge is triggered by a separate call to IPS\MFA\MFAHandler::accessToArea() that you have to write yourself, in your own controller. An MFAArea extension with no matching accessToArea() call protects nothing, and an accessToArea() call with no matching extension can never be switched on. Both halves fail silently on their own, which is where nearly everything in this article comes from.
The contract
IPS\Extensions\MFAAreaAbstract, at system/Extensions/MFAAreaAbstract.php, is one of the two smallest extension abstracts in the suite (tied with GroupLimitsAbstract.php). It is twenty-nine lines, most of them a licence header and the SUITE_UNIQUE_KEY guard, and declares exactly one member:
namespace IPS\Extensions;
abstract class MFAAreaAbstract
{
/**
* Is this area available and should show in the ACP configuration?
*
* @return bool
*/
abstract public function isEnabled(): bool;
}
That is the whole class body — the rest of the file is the copyright docblock and the SUITE_UNIQUE_KEY guard. No properties, no constructor, no optional methods. isEnabled() is abstract, so you must declare it or the class will not compile.
Read the next sentence carefully, because the docblock above is misleading. In 5.0.19 nothing in the suite calls isEnabled() on an MFAArea extension. The return value is never read. Returning FALSE does not hide the area from the ACP configuration form. The method is required by the compiler and ignored by the runtime. Why that is true is explained under "isEnabled() returns FALSE and the option is still on the form" below.
The file belongs at applications/<app>/extensions/core/MFAArea/<Class>.php, in namespace IPS\<app>\extensions\core\MFAArea, and must be listed in applications/<app>/data/extensions.json:
{
"core": {
"MFAArea": {
"MyArea": "IPS\\myapp\\extensions\\core\\MFAArea\\MyArea"
}
}
}
The Developer Center's template for a new extension, at applications/core/data/defaults/extensions/MFAArea.txt, generates a class whose body is return TRUE;. Six of core's seven implementations keep that body verbatim; DeviceManagement.php returns Settings::i()->device_management instead. Of Nexus's six, three return TRUE (Addresses, Alternatives, BillingInfo) and three return a real condition (AccountCredit, BillingAgreements, Cards). As the next section explains, none of those conditions currently has any effect.
The two identifiers you must keep in step
Your area is identified everywhere by the string <appDirectory>_<extensionKey>. The app directory is the directory your extension lives in, not the core that appears in the path and namespace. The extension key is the key in extensions.json, which matches the file name. So applications/nexus/extensions/core/MFAArea/Cards.php is the area nexus_Cards.
That string is built in IPS\Application::allExtensions() at system/Application/Application.php:419:
$appExtensions[ $application->directory . '_' . $key ] = $class;
The same string is what accessToArea() looks for in the setting, at system/MFA/MFAHandler.php:117. If the two arguments you pass to accessToArea() do not combine into the same string, your call silently does nothing, forever.
Language keys
The label on the tick box is the language key MFA_<appDirectory>_<extensionKey>. An optional second key, MFA_<appDirectory>_<extensionKey>_desc, is shown as smaller descriptive text under the label if it exists; IPS\Helpers\Form\CheckboxSet::html() tests for it with checkKeyExists() at system/Helpers/Form/CheckboxSet.php:71-75, so leaving it out is legitimate.
The main key is not optional in practice. If it is missing, an administrator sees a tick box labelled MFA_myapp_MyArea. (addToStack() itself returns an md5 placeholder; the substitution happens later in Lang::replaceWords(), which falls back to $replacement = $values['key'] — the raw key — when the word cannot be loaded, at system/Lang/Lang.php:2131-2134. No exception, no log.) The Developer Center knows about this: applications/core/modules/admin/developer/details.php:577 lists 'MFAArea' => 'MFA_{app}_{key}' in its language-scan table, so a missing key is reported under "MFAArea Extensions" on the Developer Center's language check.
Core's keys are worth reading as examples of the tone expected — they are phrased as the thing the member is doing, not as the name of the area. From applications/core/dev/lang/login.php:373-382:
'MFA_core_AuthenticateAdmin' => "Logging into AdminCP", 'MFA_core_EmailChange' => "Changing email address", 'MFA_core_EmailChange_desc' => "The user will also need to re-enter their password.",
Who calls it
One method, in one file. IPS\MFA\MFAHandler::areas(), at system/MFA/MFAHandler.php:52-60:
public static function areas(): array
{
$return = array();
foreach ( Application::allExtensions( 'core', 'MFAArea', FALSE, 'core', NULL, FALSE ) as $k => $v )
{
$return[ $k ] = "MFA_{$k}";
}
return $return;
}
Note the arguments against the signature at system/Application/Application.php:355. $checkAccess is FALSE, so no application access check is performed and your extension is listed even for an app that is restricted to certain groups. $firstApp is 'core', so core's seven areas are always listed before anyone else's. The final FALSE is $construct — the extension objects are never instantiated. areas() receives class names, uses only the array keys, and throws the class names away.
areas() in turn has exactly one caller, the ACP form at applications/core/modules/admin/settings/mfa.php:251:
$form->add( new CheckboxSet( 'security_questions_areas', Settings::i()->security_questions_areas ? explode( ',', Settings::i()->security_questions_areas ) : array_keys( MFAHandler::areas() ), FALSE, array( 'options' => MFAHandler::areas() ), NULL, NULL, NULL, 'security_questions_areas' ) );
That is the entire consumption of this extension point. The saved value is a comma-separated string, joined at mfa.php:282.
The other half: triggering the challenge
The runtime side never touches the extension. MFAHandler::accessToArea(), at system/MFA/MFAHandler.php:106, takes the app and area as plain strings and compares them against the setting:
public static function accessToArea( string $app, string $area, Url $url, ?Member $member = NULL ): ?string
/* If MFA is not enabled for this area, do nothing */
if ( !Settings::i()->security_questions_areas or !in_array( "{$app}_{$area}", explode( ',', Settings::i()->security_questions_areas ) ) )
{
return NULL;
}
It returns NULL when the member may proceed, or a string of HTML — the challenge form, the setup form, or the lockout screen — which the caller must render instead of the page. Nexus shows the standard pattern, at applications/nexus/modules/front/clients/cards.php:61-65:
if ( $output = MFAHandler::accessToArea( 'nexus', 'Cards', Url::internal( 'app=nexus&module=clients&controller=cards', 'front', 'clientscards' ) ) )
{
Output::i()->output = Theme::i()->getTemplate('clients')->cards( array() ) . $output;
return;
}
The URL you pass is the URL the member is sent back to after passing the challenge, so it must be the page you are protecting. The $member argument defaults to Member::loggedIn(); the login flows pass an explicit member because the member is not logged in yet.
isEnabled() returns FALSE and the option is still on the form
This is the trap most likely to catch a third-party developer, because the docblock in the abstract, in every core implementation and in the Developer Center's own template all say "Is this area available and should show in the ACP configuration?".
Grep the 5.0.19 source for MFAArea and exactly one line of executable code asks for the extension type at all: MFAHandler.php:55, inside areas(). The other matches are the abstract itself, two docblock mentions in MFAHandler.php (lines 100 and 101), the language-scan table at details.php:577 — which reads extensions.json as a file and never loads a class — the Developer Center template, the extensions.json registrations, and the ext__MFAArea/devscan__strings_mfaarea language strings. Nothing else. And areas() passes $construct=FALSE, so Application::constructExtensionClass() (declared at Application.php:469, called from Application.php:451 only when $construct is true) is never reached and no MFAArea object is ever created. A method that is never called on an object that is never built cannot influence anything.
Both core and Nexus write real logic there in good faith — applications/core/extensions/core/MFAArea/DeviceManagement.php returns Settings::i()->device_management, applications/nexus/extensions/core/MFAArea/Cards.php returns Settings::i()->card_storage_gateways, and AccountCredit.php returns a check on nexus_min_topup and nexus_payout — and on a 5.0.19 site those checks have no effect: "Managing stored cards" appears on the MFA settings form whether or not any gateway supports card storage.
What this means for you. Write isEnabled() because you must, put your real condition in it because a future version may start honouring it, and do not rely on it for anything. If your area must disappear when a feature is switched off, the place to enforce that is your own controller: skip the accessToArea() call, or do not reach that code path at all. This is a behaviour that could change in a later 5.x release, so treat the observation as true of 5.0.19 specifically.
The option appears, an admin ticks it, and nobody is ever challenged
No error, no log entry. The tick box works, the setting saves, and the site behaves exactly as before.
The extension only creates the option. Nothing in core scans your application and inserts a challenge for you. If you never call MFAHandler::accessToArea() from the controller you meant to protect, the ticked box is inert. Every protected area in the suite has a hand-written call site — there are twenty-two of them across core and Nexus, in system/Dispatcher/Dispatcher.php, system/Login/Success.php, applications/core/modules/admin/system/login.php, applications/core/modules/front/system/settings.php (eight on its own), login.php, register.php, lostpass.php, the six Nexus client-area controllers and applications/nexus/modules/front/checkout/checkout.php.
The same silence applies to ignoring the return value. On an ordinary page load accessToArea() has no side effect that stops execution — it returns a string and leaves it to you. (It is not completely inert: on requests that carry _mfa=optout or mfa_auth it calls Session::i()->csrfCheck(), at MFAHandler.php:137 and 229 and 267, which will call Output::i()->error() and halt if the CSRF key is wrong. That only happens on a submission of its own form.) If you call it and discard the string, the member is shown your normal page and the protection is gone.
accessToArea() always returns NULL and I cannot see why
Four separate causes, all silent, in rough order of likelihood.
- The area string does not match. Your extension is
applications/myapp/extensions/core/MFAArea/MyArea.php, so the stored key ismyapp_MyArea, but you copied a core call site and wroteaccessToArea( 'core', 'MyArea', $url ). The comparison is a plainin_array()on"{$app}_{$area}"; a miss just returnsNULL. The first argument is your application's directory. - The setting is empty.
security_questions_areasships with a default of""(applications/core/data/build.xml:407), and the first condition inaccessToArea()is!Settings::i()->security_questions_areas. On a site where the MFA settings form has never been saved, no area is protected, including core's own, even though the form shows every box ticked. The form pre-ticks everything only as a display default when the stored value is empty — see the ternary atmfa.php:251. Nothing is actually enabled until an administrator presses Save. - No handler is usable. After the area check,
accessToArea()builds the list of handlers whereisEnabled()andmemberCanUseHandler( $member )are both true, and returnsNULLif that list is empty (MFAHandler.php:150-162). On a site with no two-factor method configured at all, every area returnsNULL. Enable Google Authenticator or security questions before testing. - The recovery constant is set.
DISABLE_MFA, defined asFALSEatinit.php:176and overridable inconstants.php, short-circuitsaccessToArea()toNULLbefore anything else. There is a matchingDEV_FORCE_MFA(init.php:454) that bypasses the "already authenticated this session" check, which is what you want while developing.
My new area does not appear on the MFA settings form
Assuming extensions.json is correct — and it is only rewritten by Application::buildExtensionsJson(), which runs from the Developer Center's create and delete actions and nowhere else — the cause is almost always the datastore cache.
allExtensions() stores the resolved class-name map under the datastore key extensions, keyed by extension type. Once MFAArea is in there, the directory walk never runs again. That key is listed in Application's instance property $caches at system/Application/Application.php:117 (protected array $caches = array( 'updatecount_applications', 'applications', 'extensions' );), which ActiveRecord::clearCaches() unsets from save() and delete() (system/Patterns/ActiveRecord.php:553 and 577) — so it is cleared when an application record is saved — install, enable, disable, uninstall — and by ACP → Support → Clear Caches. Copying a file in and editing the JSON by hand clears nothing.
Two secondary causes. Application::extensions() skips any entry whose class fails class_exists(), with a bare continue and no log (Application.php:926-930), so a class name in the JSON that does not match the class declared in the file drops the area in total silence. And allExtensions() skips third-party applications entirely while RECOVERY_MODE is on (Application.php:396) — and then writes that filtered list to the datastore, where it survives recovery mode being turned back off.
My area is on the form but unticked after an upgrade, and members are not challenged
Expected behaviour, and worth telling your customers about in your release notes. The pre-tick-everything default at mfa.php:251 only applies when security_questions_areas is empty. On any site where the form has been saved once, the stored string is non-empty, so a newly installed area is rendered unticked. Adding an MFAArea extension in an update never enables it; an administrator has to open the form and tick it.
Fatal error: contains 1 abstract method and must therefore be declared abstract
The exact text:
Fatal error: Class IPS\myapp\extensions\core\MFAArea\MyArea contains 1 abstract method and must therefore be declared abstract or implement the remaining methods (IPS\Extensions\MFAAreaAbstract::isEnabled)
Because isEnabled() is abstract, omitting it is a compile-time error, raised when the autoloader loads your class during the class_exists( $classname ) call inside Application::extensions() (system/Application/Application.php:926). Nothing catches it: it is an E_ERROR at file-compile time, so neither the try/catch in constructExtensionClass() nor the set_error_handler()/set_exception_handler() pair registered at init.php:632-633 can intercept it, and IPS registers no shutdown handler.
Where this differs from most extension points is when you will see it. The MFAArea list is built lazily, only when something asks for that extension type, and the only thing that ever asks is the ACP MFA settings form. So a missing isEnabled() does not break the front end, does not break your own app, and does not break the rest of the ACP — it produces a white screen on one ACP settings page, which an administrator may not visit for months. Load app=core&module=settings&controller=mfa after adding the extension, as a smoke test.
The member passed the challenge somewhere else and walks straight into my area
Not a bug in your code. Success is recorded once, globally, in $_SESSION['MFAAuthenticated'] as a timestamp (MFAHandler.php:236 and 272), and the early-return check reads it without reference to the area (MFAHandler.php:123):
if ( !DEV_FORCE_MFA and ( isset( $_SESSION['MFAAuthenticated'] ) and ( !Settings::i()->security_questions_timer or ( ( $_SESSION['MFAAuthenticated'] + ( Settings::i()->security_questions_timer * 60 ) ) > time() ) ) ) )
{
return NULL;
}
One challenge satisfies every area until security_questions_timer minutes have passed, and a timer of 0 means "for the whole session". A member who has just logged in with two-factor authentication will not be challenged again by your area. If your area guards something that genuinely needs a fresh check every time, MFA is not the mechanism — core re-asks for the password separately for email and password changes, which is why the language strings for those areas say "The user will also need to re-enter their password."
There is also a per-member opt-out. If mfa_required_groups is not '*' and the member is not in a listed group, a member who has previously opted out is returned NULL for every area (MFAHandler.php:129-147). Your area cannot override that.
A complete working example
Taken from Nexus, which is the closest thing in the suite to a third-party implementation. Three files plus one call site.
applications/nexus/extensions/core/MFAArea/Cards.php, in full apart from the IPS copyright docblock above the namespace line:
<?php
namespace IPS\nexus\extensions\core\MFAArea;
/* To prevent PHP errors (extending class does not exist) revealing path */
use IPS\Extensions\MFAAreaAbstract;
use IPS\Settings;
use function defined;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
/**
* Multi-Factor Authentication Area
*/
class Cards extends MFAAreaAbstract
{
/**
* Is this area available and should show in the ACP configuration?
*
* @return bool
*/
public function isEnabled(): bool
{
return Settings::i()->card_storage_gateways;
}
}
The registration, in applications/nexus/data/extensions.json:
{
"core": {
"MFAArea": {
"Cards": "IPS\\nexus\\extensions\\core\\MFAArea\\Cards"
}
}
}
The language string, in applications/nexus/data/lang.xml:2210:
<word key="MFA_nexus_Cards" js="0">Managing stored cards</word>
And the call site that actually enforces it — in execute(), not in the manage() method, at applications/nexus/modules/front/clients/cards.php:61. Putting it in execute() before the parent::execute() that dispatches to manage() is what makes it cover every action of the controller at once:
if ( $output = MFAHandler::accessToArea( 'nexus', 'Cards', Url::internal( 'app=nexus&module=clients&controller=cards', 'front', 'clientscards' ) ) )
{
Output::i()->output = Theme::i()->getTemplate('clients')->cards( array() ) . $output;
return;
}
parent::execute();
Nexus renders an empty version of the page behind the challenge so the member is not dropped onto a blank screen. applications/nexus/modules/front/clients/info.php:57-65 does the same with a form. Neither is required; you may equally assign $output to Output::i()->output on its own.
A core detail that looks like your bug but is not
Inside the "try another way to sign in" branch, MFAHandler.php:194 reads:
if ( $app === 'core' and $area === 'AuthenticateFront' and !in_array( "app_AuthenticateFrontKnown", explode( ',', Settings::i()->security_questions_areas ) ) )
Stored area keys are always <appDirectory>_<extensionKey>, so the value in this setting is core_AuthenticateFrontKnown, never app_AuthenticateFrontKnown. The literal cannot match, so the negated test is always true and the known-device recovery option is offered whenever the member has any row in core_members_known_devices. It affects only core's own front-end login area. Nothing a third-party MFAArea does can trigger or avoid it; it is noted here so you do not spend an afternoon looking for it in your own code.
Removing an area
Deleting the extension removes the tick box from the form. It does not remove the key from security_questions_areas: no code in the suite rewrites that setting except the MFA settings form itself, so a stale myapp_MyArea entry stays in the comma-separated string after your app is uninstalled. It is harmless — nothing will ever compare against it again — but it will still be sitting in core_sys_conf_settings and will be silently discarded the next time an administrator saves the form. Whether an app should clean this up in its core/Uninstall extension is a judgement call; core does not.
Verified against
Read from the source of Invision Community 5.0.19 (latest upgrade step applications/core/setup/upg_5001908). Key files: system/Extensions/MFAAreaAbstract.php, system/MFA/MFAHandler.php, system/Application/Application.php (allExtensions(), extensions(), constructExtensionClass(), $caches), applications/core/modules/admin/settings/mfa.php, applications/core/modules/admin/developer/details.php, applications/core/data/defaults/extensions/MFAArea.txt, system/Helpers/Form/CheckboxSet.php, system/Dispatcher/Dispatcher.php, system/Login/Success.php, the seven implementations under applications/core/extensions/core/MFAArea/ and the six under applications/nexus/extensions/core/MFAArea/. The claim that isEnabled() is never invoked is based on a full-source grep for MFAArea and on areas() passing $construct=FALSE; it is a statement about 5.0.19 and may not hold in later releases.
Recommended Comments