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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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