Sending email from an application, without a single password
Microsoft turned off SMTP with a password. The answer is not to store a better secret: it is to store no secret at all. Managed identity, Microsoft Graph, and Mail.Send tied to a group of mailboxes. Here is the whole configuration, traps included.
An application needs to send an email. A confirmation, an alert, a receipt. The first solution that comes to mind is the wrong one: a mailbox, a password, and that password dropped into an environment variable.
It works on day one. And it is a bomb with a timer: Microsoft is turning off SMTP with a password, the secret leaks with the code or with the backup, and whoever picks it up sends mail in the company's name. It does not have to be that way: an application can send email without storing a single password. Here is the whole configuration, from start to finish, with the traps that cost us a morning along the way.
The problem: the password that should not exist
The old path is easy to write and hard to defend. The secret lives somewhere, and somewhere is always one place too many.
// Don't do this: the password lives with the code, and leaks with it.
const user = "noreply@company.com";
const pass = process.env.SMTP_PASSWORD; // a secret that someone has to keep
await smtp.send({ user, pass, to, subject, html });
Underneath there is a bigger problem. Microsoft disables SMTP AUTH by default on new tenants, and is removing it from the old ones. The code above stops working without warning, and the instinctive reaction, re-enabling SMTP AUTH, opens exactly the door Microsoft is closing.
The right path: identity instead of a secret
The application runs on Azure, and Azure knows who it is. It is called a managed identity: the platform hands the application a token, on request, with no password existing anywhere. There is no secret to store, to rotate, or to leak.
The email goes out through Microsoft Graph, not through SMTP. Microsoft Graph accepts that token, checks that the identity is authorised, and delivers. All authentication becomes the platform's problem, and stops being a file of ours.
The code
It is fewer lines than the password version, and it is missing the field an attacker wants. DefaultAzureCredential handles the token: in the cloud it uses the managed identity, on our machine it uses the az login session.
import { DefaultAzureCredential } from "@azure/identity";
const credential = new DefaultAzureCredential();
const SENDER = "hello@company.com";
async function send(to: string, subject: string, html: string): Promise<void> {
const token = await credential.getToken("https://graph.microsoft.com/.default");
if (!token) throw new Error("no token for Microsoft Graph");
const res = await fetch(
`https://graph.microsoft.com/v1.0/users/${encodeURIComponent(SENDER)}/sendMail`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
message: {
subject,
body: { contentType: "HTML", content: html },
toRecipients: [{ emailAddress: { address: to } }],
},
saveToSentItems: true,
}),
},
);
if (!res.ok) throw new Error(`sendMail ${res.status}: ${await res.text()}`);
}
The same code runs in both places, and in neither is a password written down. What is missing is the less obvious part: telling Exchange that this identity can only send from one mailbox, and from no other.
Tying the send to a group of mailboxes
The Microsoft Graph Mail.Send permission, granted on its own, lets the application send in the name of any mailbox in the tenant, the administrator's included. The restriction is done in Exchange, with RBAC for applications. And the scope should not be a fixed mailbox, but a group: that way we add or remove mailboxes later, without touching the assignment again.
# A mail-enabled security group is the scope: add or remove mailboxes later,
# without ever touching the role assignment again.
New-DistributionGroup -Name "api-senders" -Type Security `
-PrimarySmtpAddress "api-senders@company.com"
Add-DistributionGroupMember -Identity "api-senders" -Member "hello@company.com"
With the group created, we register the identity in Exchange, define a scope that resolves to the group's members, and grant Mail.Send tied to that scope.
# Register the managed identity in Exchange. Use the AppId, never the ObjectId.
New-ServicePrincipal -AppId $appId -ObjectId $objectId -DisplayName "api"
# A scope that resolves to the group's members, and to nobody else.
New-ManagementScope -Name "only-api-senders" `
-RecipientRestrictionFilter "MemberOfGroup -eq '$groupDn'"
# Grant Mail.Send, restricted to that scope.
New-ManagementRoleAssignment -App $servicePrincipalId `
-Role "Application Mail.Send" -CustomResourceScope "only-api-senders"
The AppId is required, not the ObjectId. Swapping them gives a 403 «Blocked by tenant configured AppOnly AccessPolicy settings», an error that does not say what is wrong and sends you looking in the wrong place.
The traps that cost the whole security
The first one already appeared: AppId, never ObjectId. The second is worse, because the configuration looks right and is not. The Entra and Exchange RBAC permissions are additive. If the identity also has a Mail.Send granted in Entra, with no scope, the union of the two cancels the restriction, and the application can once again send as any mailbox.
The rule is a single one: Mail.Send exists only in Exchange RBAC, restricted to the group. In Entra, zero. If the consent is there, remove it, or all the work done in Exchange counts for nothing.
Propagation takes time
Once configured, we tested, and it failed. We went back, reviewed everything, it was right, and it kept failing. The problem was not the configuration: it was time. RBAC changes in Exchange Online are not immediate, they replicate across the service and can take up to half an hour to hold everywhere.
The lesson cost us the morning: after touching the scope or the assignment, wait before concluding it is wrong. Test-ServicePrincipalAuthorization itself can answer with the old state during that window. Changing more things in that interval is the best way to break what was already right.
How you prove it is locked
A restriction you cannot demonstrate does not count. Exchange answers the question directly, mailbox by mailbox, and this is the proof we keep.
Test-ServicePrincipalAuthorization -Identity "api" -Resource hello@company.com
# InScope : True -> can send as hello@, which is what we want
Test-ServicePrincipalAuthorization -Identity "api" -Resource ceo@company.com
# InScope : False -> cannot, and that is exactly why the CEO mailbox is safe
What is left
An application that sends email, with no password anywhere, and that can only send from the group we gave it. If it is compromised tomorrow, the attacker finds no secret to steal, and writes in nobody else's name. It is not a cannon to kill a fly: it is the smallest permission that solves the problem, and the proof that it really is the smallest.



