Envoyer un email depuis une application, sans un seul mot de passe
Microsoft a coupé le SMTP avec mot de passe. La réponse n'est pas de stocker un meilleur secret : c'est de n'en stocker aucun. Identité managée, Microsoft Graph, et Mail.Send restreint à un groupe de boîtes aux lettres. Voici toute la configuration, erreurs comprises.
Une application doit envoyer un email : une confirmation, une alerte, un reçu. La première solution qui vient à l'esprit est la mauvaise : une boîte aux lettres, un mot de passe, et ce mot de passe glissé dans une variable d'environnement.
Cela fonctionne le premier jour. Et c'est une bombe à retardement : Microsoft coupe le SMTP avec mot de passe, le secret fuit avec le code ou avec la sauvegarde, et celui qui le récupère écrit au nom de l'entreprise. Il n'y a pourtant aucune fatalité : une application peut envoyer des emails sans stocker le moindre mot de passe. Voici toute la configuration, du début à la fin, avec les erreurs qui nous ont coûté une matinée en chemin.
Le problème : le mot de passe qui ne devrait pas exister
L'ancienne méthode est facile à écrire et difficile à défendre. Le secret vit quelque part, et ce quelque part est toujours un endroit de trop.
// 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 });
Il y a en dessous un problème plus grave. Microsoft désactive le SMTP AUTH par défaut sur les nouveaux tenants et le retire des anciens. Le code ci-dessus cesse donc de fonctionner sans préavis, et le réflexe instinctif, réactiver le SMTP AUTH, rouvre exactement la porte que Microsoft est en train de fermer.
Le bon chemin : une identité au lieu d'un secret
L'application tourne sur Azure, et Azure sait qui elle est. Cela s'appelle une identité managée : la plateforme remet à l'application un jeton, à la demande, sans qu'aucun mot de passe n'existe nulle part. Il n'y a aucun secret à stocker, à faire tourner, ni à laisser fuir.
L'email part par Microsoft Graph, et non par SMTP. Graph accepte ce jeton, vérifie que l'identité est autorisée, puis délivre le message. Toute l'authentification devient l'affaire de la plateforme et cesse d'être un fichier chez nous.
Le code
Cela représente moins de lignes que la version avec mot de passe, et il y manque le champ qui intéresse un attaquant. DefaultAzureCredential se charge du jeton : dans le cloud, il utilise l'identité managée ; sur notre machine, la session az login.
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()}`);
}
Le même code tourne aux deux endroits, et nulle part un mot de passe n'est écrit. Reste la partie la moins évidente : dire à Exchange que cette identité ne peut envoyer que depuis une boîte aux lettres précise, et depuis aucune autre.
Lier l'envoi à un groupe de boîtes
La permission Mail.Send de Microsoft Graph, accordée seule, laisse l'application envoyer au nom de n'importe quelle boîte du tenant, celle de l'administrateur comprise. La restriction se pose dans Exchange, avec le RBAC pour applications. Et la portée ne doit pas être une boîte fixe, mais un groupe : on peut ainsi ajouter ou retirer des boîtes par la suite, sans jamais retoucher à l'attribution.
# 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"
Une fois le groupe créé, nous enregistrons l'identité dans Exchange, définissons une portée qui se résout vers les membres du groupe, puis accordons Mail.Send restreint à cette portée.
# 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"
C'est l'AppId qui est obligatoire, pas l'ObjectId. Les intervertir donne un 403 « Blocked by tenant configured AppOnly AccessPolicy settings », une erreur qui ne dit rien de ce qui cloche et nous envoie chercher au mauvais endroit.
Les deux erreurs qui coûtent toute la sécurité
La première est déjà apparue : AppId, jamais ObjectId. La seconde est pire, car la configuration a l'air correcte sans l'être. Les permissions d'Entra et celles du RBAC d'Exchange s'additionnent : si l'identité dispose aussi d'un Mail.Send accordé dans Entra, sans portée, l'union des deux annule la restriction, et l'application peut de nouveau écrire depuis n'importe quelle boîte.
La règle est simple : Mail.Send n'existe que dans le RBAC d'Exchange, restreint au groupe. Dans Entra, rien. Si le consentement y figure, on le retire, faute de quoi tout le travail fait dans Exchange ne sert à rien.
La propagation prend du temps
Une fois la configuration en place, nous avons testé, et cela a échoué. Nous sommes revenus en arrière, nous avons tout revérifié, tout était correct, et cela continuait d'échouer. Le problème n'était pas la configuration : c'était le délai. Les changements de RBAC dans Exchange Online ne sont pas immédiats ; ils se répliquent à travers le service et peuvent mettre jusqu'à une demi-heure à s'appliquer partout.
La leçon nous a coûté une matinée : après avoir touché à la portée ou à l'attribution, il faut attendre avant de conclure à une erreur. Test-ServicePrincipalAuthorization lui-même peut répondre selon l'ancien état pendant cette fenêtre. Modifier autre chose dans cet intervalle est le meilleur moyen de casser ce qui était déjà correct.
Comment on prouve que c'est verrouillé
Une restriction que l'on ne démontre pas ne compte pas. Exchange répond directement à la question, boîte par boîte, et c'est cette preuve que nous conservons.
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
Ce qui reste
Une application qui envoie des emails, sans aucun mot de passe nulle part, et qui ne peut écrire que depuis le groupe que nous lui avons attribué. Si elle est compromise demain, l'attaquant ne trouve aucun secret à voler et n'écrit au nom de personne d'autre. Ce n'est pas un canon pour tuer une mouche : c'est la plus petite permission qui résout le problème, assortie de la preuve qu'elle est bien la plus petite.

