Pin the MFA policy: enforcement lives in Entra ID Conditional Access at the tenant level (org IT responsibility) - the application code does not implement MFA mechanics. The BFF performs a defense-in-depth sanity-check on the id_token amr claim at session creation; sessions without evidence of multi-factor authentication are rejected. The accepted amr values are maintained in a small in-source list and reviewed on cadence. Step-up MFA is designed-in for v1 but dormant: a @RequireMfa() decorator and a RequireMfaGuard ship in the codebase, the session payload carries mfaVerifiedAt, and the SPA HTTP interceptor handles the 401 + claims challenge round trip. No v1 route is annotated, since v1 has no admin UI or other operations sensitive enough to require fresh MFA. The hooks are kept alive by automated tests so they don't drift. Authentication Context Classes (ACR-based step-up) are not used in v1; they remain a future option if specific operations later demand them. Service-account / app-only flows are out of scope. decisions/README.md index updated. CLAUDE.md gains an explicit 'MFA' line pointing to ADR-0011.
12 KiB
status, date, decision-makers, tags
| status | date | decision-makers | tags | |
|---|---|---|---|---|
| accepted | 2026-04-29 | R&D Lead |
|
MFA enforcement — Entra ID Conditional Access baseline, BFF claim sanity-check, step-up hooks designed-in
Context and Problem Statement
Multi-factor authentication is mandatory for the portal's workforce audience. The question is where MFA logic lives, how the BFF gains assurance that an authenticated session has actually been multi-factored, and how the application is prepared for future step-up MFA on sensitive operations — without writing any of it in v1.
Identity is Microsoft Entra ID (ADR-0008) and the auth flow is OIDC Authorization Code + PKCE via MSAL Node (ADR-0009). The v1 portal has no admin UI and no defined "sensitive" operations beyond authentication itself, so step-up MFA is a design concern, not a v1 implementation concern.
Decision Drivers
- MFA is a non-negotiable security baseline for workforce.
- Application code must not implement MFA mechanics — that belongs in the IdP. "Anti-bricolage" applies particularly here.
- The mechanism chosen at sign-in must be auditable in the session (was MFA actually performed?).
- The architecture must support future step-up MFA (a sensitive operation requires fresh MFA, beyond what was done at sign-in) without retrofitting authorization.
- Configuration responsibility must be clearly separated: the IdP (org IT) owns the policy; the BFF owns the verification.
Considered Options
Where MFA is enforced
- Microsoft Entra Conditional Access policy at the tenant level. (Chosen.)
- MFA logic in the BFF (e.g., a second-factor prompt after password).
How the BFF validates that MFA happened
- Sanity-check the
amrclaim of the id_token at session creation. (Chosen.) - Require an explicit Authentication Context Class Reference (ACR) value via
acr_values/claimsrequest parameter. - Trust Entra unconditionally without verification.
Step-up MFA in v1
- Designed-in via a
@RequireMfa()decorator and a claims-challenge mechanism, but not applied to any v1 route. (Chosen.) - Built and exercised in v1 — premature, no consumer.
- Deferred entirely (no decorator, no hook) — costly to retrofit.
Decision Outcome
MFA enforcement lives in Microsoft Entra Conditional Access. The org's IT/identity contact configures a Conditional Access policy at the tenant level that requires MFA for any sign-in to the BFF's app registration, for both the prod tenant and the M365 Developer tenant used for non-prod. Recommended settings (org-side, not in our code):
- Assignment. Cloud apps → the BFF app. Users → All users (or "All workforce", depending on how the org structures groups). Excluded: break-glass accounts, by Microsoft's own recommendation.
- Conditions (recommended, not strictly required): device compliance, location, sign-in risk (Entra ID P2 for risk-based — opt-in).
- Grant. Require multi-factor authentication. Prefer phishing-resistant methods (FIDO2 / passkeys / Windows Hello for Business). Fall back to authenticator-app push or TOTP. SMS/voice deprecated, to be avoided.
This policy is out of scope for application code. Our app does not implement MFA — it consumes the MFA outcome.
BFF verification. At session creation (after the OIDC callback exchange and id_token validation), the BFF performs a sanity-check on the amr claim of the id_token. The session is rejected with a 401 and an explicit error if the claim is absent, empty, or contains only password-class values without an MFA-class indicator. The expected normal value emitted by Entra after a CA-enforced MFA sign-in is ["pwd","mfa"] (or equivalent depending on the factor).
This is defense in depth, not the primary control: the primary control is the CA policy. The sanity-check exists so that a misconfigured or disabled CA policy does not silently regress the security posture without anyone noticing — the BFF will simply refuse to grant sessions if MFA assurance is missing.
A small validation table maintained in the BFF (in source, reviewed in PRs) lists the amr values accepted as multi-factor:
// Values Entra emits in 'amr' that we treat as evidence of MFA having occurred.
// Not exhaustive — extended on review when Entra emits new values.
const MFA_AMR_VALUES = ['mfa', 'otp', 'fido', 'wia', 'phr'] as const;
This list is reviewed against Microsoft's documentation as part of the security review cadence.
Step-up MFA — designed-in, dormant. A @RequireMfa() controller-method decorator and an associated guard are implemented in v1, but no v1 route uses them. The guard, when active, will:
- Inspect the session for a recent MFA assertion (a
mfaVerifiedAttimestamp added to the session payload, set tolastSeenAtof the auth callback). - If the timestamp is older than a configurable freshness window (
MFA_FRESHNESS_SECONDS, default 600 = 10 min), respond with 401 + aWWW-Authenticateheader carrying a claims challenge (an opaque blob produced by MSAL Node). - The Angular SPA HTTP interceptor catches the 401 + claims challenge, redirects the browser to
/auth/login?claims=<challenge>, which forwards the challenge to Entra. Entra re-prompts MFA, returns a fresheramr/mfaVerifiedAt, and the original request is retried.
Authentication Context Classes (acr_values) are not used in v1. The freshness-timestamp approach is sufficient for the baseline. ACR-based step-up (e.g., "this action requires CA policy c2") will be considered later, gated behind a follow-up ADR if specific operations demand it.
Service accounts / app-only tokens. Out of scope. The BFF authenticates end-users via OIDC; service-to-service tokens (if needed for downstream APIs in a future ADR) follow a different flow — On-Behalf-Of or app-only — and have their own MFA story (i.e., none, because no human is involved).
Consequences
- Good, because MFA mechanics live where they belong (the IdP). The application is not in the business of implementing OTP delivery, push notifications, or hardware-key challenges.
- Good, because the IdP configuration can evolve independently — adding device compliance, sign-in risk, location-based rules, or moving to phishing-resistant-only — without code changes.
- Good, because the
amrsanity-check in the BFF catches a category of misconfiguration that would otherwise silently regress security. - Good, because step-up MFA is designed-in via a single decorator. When a sensitive operation appears in a future feature, marking it requires only
@RequireMfa()— no architectural change. - Good, because separating freshness-based step-up (default) from ACR-based step-up (advanced) keeps v1 simple while leaving a clean upgrade path.
- Bad, because the
amrvalue list is implicitly tied to Entra's emitted values; if Microsoft introduces a new factor with a newamrtoken, the BFF rejects sessions until the list is updated. Mitigated by a documented review cadence. - Bad, because step-up MFA needs SPA cooperation (the HTTP interceptor that handles 401 + claims challenge); if a future SPA feature bypasses the interceptor, step-up will silently degrade to plain 401. Mitigated by integration tests on the interceptor.
- Bad, because Conditional Access policies — especially advanced ones — require Entra ID P1 (and P2 for risk-based). Already flagged in ADR-0008, restated here.
- Neutral, because v1 does not exercise the step-up path. Hooks must be kept alive by automated tests, not by being used in production traffic.
Confirmation
apps/portal-bff/src/auth/mfa.tsexports theMFA_AMR_VALUESconstant and awasMultiFactor(claims): booleanhelper.- The OIDC callback path rejects sessions for which
wasMultiFactor(idTokenClaims)is false, with a structured 401 (logged as an audit event in the future audit-log ADR). - Each session payload carries
mfaVerifiedAt: number(epoch ms), set at the callback and refreshed on any subsequent step-up auth completion. apps/portal-bff/src/auth/require-mfa.guard.tsprovides theRequireMfaGuardand the@RequireMfa()decorator (on top of the globalAuthGuardfrom ADR-0009).MFA_FRESHNESS_SECONDSis read from env, defaulting to 600. The BFF refuses to start with a value below 60 (to catch misconfiguration).- The Angular HTTP interceptor in
portal-shellhandles401+WWW-Authenticate: Bearer error="insufficient_user_authentication"(or the claims-challenge equivalent) and redirects to/auth/loginwith the challenge propagated. - Integration tests cover: a sign-in without MFA assertion is rejected; a sign-in with valid MFA assertion succeeds; a route protected by
@RequireMfa()accepts a fresh session, rejects with claims challenge afterMFA_FRESHNESS_SECONDS, and accepts again after re-auth. - The org-side Conditional Access policy is documented in an operational runbook (out of repo, owned by IT) and reviewed during onboarding of each tenant (prod + dev).
Pros and Cons of the Options
Where MFA is enforced
Conditional Access (chosen)
- Good, because it is the Microsoft-native, audited, IdP-managed mechanism — exactly the kind of "recognised, battle-tested" choice the project values.
- Good, because changes to MFA policy are a tenant-level config, not a deployment.
- Good, because the IdP catalogue of factors evolves without us — passkeys, FIDO2, certificate-based, etc.
- Bad, because it requires Entra ID P1 minimum at the tenant level (cost flagged in ADR-0008).
MFA logic in the BFF
- Bad, because it would re-implement OTP/push/key flows the IdP already provides — exactly the bricolage we are forbidden.
- Bad, because secret material (TOTP shared secrets, etc.) ends up in our Postgres, expanding the attack surface.
How the BFF validates MFA
amr sanity-check (chosen)
- Good, because cheap, immediate, no extra round trip.
- Good, because catches a real failure mode (CA policy disabled, scope misconfigured).
- Bad, because dependent on Entra's
amremission values — list maintenance required.
Required ACR via claims parameter
- Good, because explicit and machine-verifiable per request.
- Bad, because requires Authentication Context Classes to be configured tenant-side and exchanged on every request — heavy for a baseline "MFA at sign-in" need. Re-evaluate when fine-grained step-up arrives.
No verification
- Bad, because a CA policy disabled by mistake would silently regress security with no audit signal until an incident.
Step-up MFA in v1
Designed-in via decorator, dormant (chosen)
- Good, because the cost is minimal: one guard, one decorator, one timestamp in the session.
- Good, because step-up is a one-line annotation on any future sensitive route.
- Bad, because a dormant code path drifts unless covered by tests — addressed in the Confirmation section.
Built and exercised in v1
- Bad, because no v1 route is sensitive enough to require it. Building a feature with no consumer is bricolage by another name.
Deferred entirely
- Bad, because retrofitting step-up after the fact means refactoring guards, sessions, and the SPA interceptor. Cost asymmetric vs. the dormant-hook option.
More Information
- Microsoft Entra Conditional Access overview: https://learn.microsoft.com/entra/identity/conditional-access/overview
amrclaim values (OpenID Connect): https://www.iana.org/assignments/authentication-method-reference-values/- Microsoft
amrdocumentation: https://learn.microsoft.com/azure/active-directory/develop/access-tokens - Authentication Context Classes (ACR) and claims challenges: https://learn.microsoft.com/entra/identity-platform/v2-conditional-access-dev-guide
- Phishing-resistant MFA (passkeys / FIDO2): https://learn.microsoft.com/entra/identity/authentication/concept-authentication-passwordless
- Related ADRs: ADR-0008 (identity model — P1 licensing flagged), ADR-0009 (auth flow), ADR-0010 (session —
mfaVerifiedAtlives here), and the future ADRs for downstream APIs (On-Behalf-Of), audit trail (rejected-MFA logged events), and the operational runbook for tenant Conditional Access policies.