You can do it, but treat it as a **tactical integration**, not a stable public API. The article explicitly says Microsoft has not provided a normal public-use API for this, and the flow relies on the tenant’s built-in **“Entra Id MFA Notification Client”** service principal and the `BeginTwoWayAuthentication` endpoint. ([Entraneer](https://www.entraneer.com/blog/entra/authentication/transactional-mfa-entra-id "Trigger Microsoft MFA for specific accounts using Powershell / Rest API | Entra ID (Azure AD)")) ## How to implement it in Node You need two parts: 1. **One-time setup** - Find the built-in service principal with app ID `981f26a1-7f43-403b-a875-f8b09b8cd720` - Add a client secret to that service principal - Securely store that secret in your app config / vault The article shows this exact service principal ID and creates a password credential on it. ([Entraneer](https://www.entraneer.com/blog/entra/authentication/transactional-mfa-entra-id "Trigger Microsoft MFA for specific accounts using Powershell / Rest API | Entra ID (Azure AD)")) 2. **Runtime flow in your Node app** - Get an access token from `https://login.microsoftonline.com/{tenantId}/oauth2/token` - Use `client_credentials` - Request the resource `https://adnotifications.windowsazure.com/StrongAuthenticationService.svc/Connector` - Send XML to `https://strongauthenticationservice.auth.microsoft.com/StrongAuthenticationService.svc/Connector//BeginTwoWayAuthentication` - Parse the XML response and act on `Success`, denial, or timeout Those are the exact token/resource and POST endpoint patterns shown in the article. ([Entraneer](https://www.entraneer.com/blog/entra/authentication/transactional-mfa-entra-id "Trigger Microsoft MFA for specific accounts using Powershell / Rest API | Entra ID (Azure AD)")) ## Recommended Node stack Use: - `axios` for HTTP - `fast-xml-parser` for response parsing - `uuid` for `ContextId` Install: ```bash npm install axios fast-xml-parser uuid ``` ## Example implementation ```js const axios = require('axios'); const { XMLParser } = require('fast-xml-parser'); const { v4: uuidv4 } = require('uuid'); const TENANT_ID = process.env.ENTRA_TENANT_ID; const MFA_CLIENT_SECRET = process.env.ENTRA_MFA_CLIENT_SECRET; // Built-in Entra MFA Notification Client app ID const MFA_CLIENT_ID = '981f26a1-7f43-403b-a875-f8b09b8cd720'; // Per article: v1 token endpoint + resource parameter const TOKEN_URL = `https://login.microsoftonline.com/${TENANT_ID}/oauth2/token`; const MFA_RESOURCE = 'https://adnotifications.windowsazure.com/StrongAuthenticationService.svc/Connector'; // Per article: updated resilient endpoint const MFA_PUSH_URL = 'https://strongauthenticationservice.auth.microsoft.com/StrongAuthenticationService.svc/Connector//BeginTwoWayAuthentication'; async function getMfaServiceToken() { const params = new URLSearchParams(); params.append('resource', MFA_RESOURCE); params.append('client_id', MFA_CLIENT_ID); params.append('client_secret', MFA_CLIENT_SECRET); params.append('grant_type', 'client_credentials'); params.append('scope', 'openid'); const res = await axios.post(TOKEN_URL, params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 10000, }); if (!res.data?.access_token) { throw new Error('No access token returned from Entra token endpoint'); } return res.data.access_token; } function buildBeginTwoWayAuthXml({ userPrincipalName, lcid = 'en-us', callerName = 'node-app', callerIP = 'UNKNOWN:', requireUserMatch = true, syncCall = true, overrideVoiceOtp = false, }) { const contextId = uuidv4(); return ` <BeginTwoWayAuthenticationRequest> <Version>1.0</Version> <UserPrincipalName>${escapeXml(userPrincipalName)}</UserPrincipalName> <Lcid>${escapeXml(lcid)}</Lcid> <AuthenticationMethodProperties xmlns:a="http://schemas.microsoft.com/2003/10/Serialization/Arrays"> <a:KeyValueOfstringstring> <a:Key>OverrideVoiceOtp</a:Key> <a:Value>${String(overrideVoiceOtp).toLowerCase()}</a:Value> </a:KeyValueOfstringstring> </AuthenticationMethodProperties> <ContextId>${contextId}</ContextId> <SyncCall>${String(syncCall).toLowerCase()}</SyncCall> <RequireUserMatch>${String(requireUserMatch).toLowerCase()}</RequireUserMatch> <CallerName>${escapeXml(callerName)}</CallerName> <CallerIP>${escapeXml(callerIP)}</CallerIP> </BeginTwoWayAuthenticationRequest>`.trim(); } async function sendTransactionalMfa(userPrincipalName) { const accessToken = await getMfaServiceToken(); const xmlBody = buildBeginTwoWayAuthXml({ userPrincipalName, callerName: 'transaction-approval', callerIP: 'UNKNOWN:', }); const res = await axios.post(MFA_PUSH_URL, xmlBody, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/xml', }, timeout: 60000, // sync call can take time while user responds responseType: 'text', }); const parser = new XMLParser({ ignoreAttributes: false, parseTagValue: true, trimValues: true, }); const parsed = parser.parse(res.data); const response = parsed?.BeginTwoWayAuthenticationResponse || parsed; const authResult = response?.AuthenticationResult; const resultValue = response?.Result?.Value || response?.Result; return normalizeMfaResult({ rawXml: res.data, authResult, resultValue, }); } function normalizeMfaResult({ rawXml, authResult, resultValue }) { const value = String(resultValue || '').toLowerCase(); if (value === 'success') { return { approved: true, status: 'approved', authResult, rawXml, }; } if (value.includes('denied') || value === 'phoneappdenied') { return { approved: false, status: 'denied', authResult, rawXml, }; } if (value.includes('noresponse') || value === 'phoneappnoresponse') { return { approved: false, status: 'timeout', authResult, rawXml, }; } return { approved: false, status: value || 'unknown', authResult, rawXml, }; } function escapeXml(value) { return String(value) .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&apos;'); } module.exports = { sendTransactionalMfa, }; ``` ## Example Express route Use it only on a high-risk action, not on every click. The article warns that overusing push MFA increases MFA fatigue risk. ([Entraneer](https://www.entraneer.com/blog/entra/authentication/transactional-mfa-entra-id "Trigger Microsoft MFA for specific accounts using Powershell / Rest API | Entra ID (Azure AD)")) ```js const express = require('express'); const { sendTransactionalMfa } = require('./mfa'); const app = express(); app.use(express.json()); app.post('/api/payments/:id/approve', async (req, res) => { try { const userUpn = req.body.userPrincipalName; if (!userUpn) { return res.status(400).json({ error: 'userPrincipalName is required' }); } const mfa = await sendTransactionalMfa(userUpn); if (!mfa.approved) { return res.status(403).json({ error: 'mfa_not_approved', status: mfa.status, }); } // Continue with sensitive action only after approval return res.json({ ok: true, message: 'MFA approved, transaction can proceed', }); } catch (err) { console.error(err?.response?.data || err.message); return res.status(500).json({ error: 'mfa_request_failed', detail: err.message, }); } }); app.listen(3000); ``` ## Important operational notes - **Secret creation is privileged**: adding a password to that built-in service principal is an admin-level action, and the article’s sample uses Graph with `Application.ReadWrite.All`. ([Entraneer](https://www.entraneer.com/blog/entra/authentication/transactional-mfa-entra-id "Trigger Microsoft MFA for specific accounts using Powershell / Rest API | Entra ID (Azure AD)")) - **This is not the same as standard app MFA**: you are directly invoking the strong auth connector endpoint used in Entra MFA plumbing tied to older AD FS / NPS style integrations. The article explains this background, and Microsoft’s NPS docs still reference these service URLs as active infrastructure. ([Entraneer](https://www.entraneer.com/blog/entra/authentication/transactional-mfa-entra-id "Trigger Microsoft MFA for specific accounts using Powershell / Rest API | Entra ID (Azure AD)")) - **Expect break risk**: because this is not documented as a normal public developer API, Microsoft could change behavior without the stability you would expect from Graph. The article explicitly warns about that. ([Entraneer](https://www.entraneer.com/blog/entra/authentication/transactional-mfa-entra-id "Trigger Microsoft MFA for specific accounts using Powershell / Rest API | Entra ID (Azure AD)")) - **Limit use carefully**: only use for truly sensitive actions like fund release, password reset verification, or high-risk approval workflows. Excess prompts increase user conditioning and approval mistakes. ([Entraneer](https://www.entraneer.com/blog/entra/authentication/transactional-mfa-entra-id "Trigger Microsoft MFA for specific accounts using Powershell / Rest API | Entra ID (Azure AD)")) ## Practical production safeguards Use these guardrails: - Require an already authenticated session before sending the push - Rate limit per user and per operator - Log who initiated the MFA push and why - Expire requests quickly - Bind the push to a transaction ID in your audit log - Fail closed on timeout / denial - Add a second approval for very high-value actions ## Best architecture for your app For your Node app, I would structure it like this: - `POST /verify-user` - sends MFA push - waits for approval - returns short-lived verification token - `POST /approve-transaction` - requires that verification token - checks the verification is recent (for example 60 seconds) - performs the protected action That way, you do not let a single MFA approval “cover” a long browser session. If you want, I can turn this into a **full production-ready Express module** with: - env validation - retry handling - XML response hardening - audit logging hooks - transaction-bound verification tokens **Orginal Article:** https://www.entraneer.com/blog/entra/authentication/transactional-mfa-entra-id