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, mistakes 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. It is also a time bomb: Microsoft is turning off SMTP with a password, the secret leaks along with the code or the backup, and whoever picks it up can send mail in the company's name. It does not have to be that way, because an application can send email without storing a single password. Here is the whole configuration, from start to finish, including the mistakes 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 older ones. The code above stops working without warning, and the instinctive reaction, re-enabling SMTP AUTH, opens precisely the door Microsoft is closing.
The right path: identity instead of a secret
The application runs on Azure, and Azure knows who it is. This is a managed identity: the platform hands the application a token on request, and no password exists 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. Authentication becomes the platform's problem instead of a secret sitting in one of our files.
The code
It is fewer lines than the password version, and it is missing the one 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 of them is a password written down anywhere. What remains is the less obvious part: telling Exchange that this identity may 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 applied in Exchange, using RBAC for applications. And the scope should not be a fixed mailbox but a group, so that mailboxes can be added or removed 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"
It is the AppId that is required here, 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 two mistakes that undo all the security
The first has already come up: AppId, never ObjectId. The second is worse, because the configuration looks right and is not. Entra and Exchange RBAC permissions are additive. If the identity also holds an unscoped Mail.Send in Entra, the union of the two cancels the restriction, and the application can once again send as any mailbox.
There is one rule: Mail.Send exists only in Exchange RBAC, restricted to the group. In Entra, none. If the consent is there, remove it, or all the work done in Exchange counts for nothing.
Propagation takes time
With everything configured, we tested it, and it failed. We went back and reviewed the lot. It was all correct, and it kept failing. The problem was not the configuration but the clock: RBAC changes in Exchange Online are not immediate. They replicate across the service and can take up to half an hour to take hold everywhere.
That lesson cost us a morning. After touching the scope or the assignment, wait before concluding that something is wrong. Test-ServicePrincipalAuthorization itself can report the old state during that window, and changing more things in the meantime is the surest 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 cannot write in anybody else's name. This is not a sledgehammer to crack a nut: it is the smallest permission that solves the problem, plus the proof that it really is the smallest.

