Enviar email desde una aplicación, sin una sola contraseña
Microsoft apagó el SMTP con contraseña. La respuesta no es guardar un secreto mejor: es no guardar ningún secreto. Identidad gestionada, Microsoft Graph, y Mail.Send atado a un grupo de buzones. Aquí está toda la configuración, trampas incluidas.
Una aplicación necesita enviar un email. Una confirmación, una alerta, un recibo. La primera solución que aparece es la equivocada: un buzón, una contraseña, y esa contraseña metida en una variable de entorno.
Funciona el primer día. Y es una bomba con temporizador: Microsoft está apagando el SMTP con contraseña, el secreto se filtra con el código o con la copia de seguridad, y quien lo recoja envía en nombre de la empresa. No tiene por qué ser así: una aplicación puede enviar emails sin guardar ninguna contraseña. Aquí está toda la configuración, de principio a fin, con las trampas que nos costaron una mañana por el camino.
El problema: la contraseña que no debería existir
El camino antiguo es fácil de escribir y difícil de defender. El secreto vive en algún sitio, y algún sitio es siempre un sitio de más.
// 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 });
Debajo hay un problema mayor. Microsoft desactiva el SMTP AUTH por defecto en los tenants nuevos, y lo está retirando de los antiguos. El código de arriba deja de funcionar sin aviso, y la reacción instintiva, reactivar el SMTP AUTH, abre exactamente la puerta que Microsoft está cerrando.
El camino correcto: identidad en vez de secreto
La aplicación corre en Azure, y Azure sabe quién es. Se llama identidad gestionada: la plataforma entrega a la aplicación un token, a petición, sin que exista ninguna contraseña en ningún sitio. No hay secreto que guardar, que rotar, ni que filtrar.
El email sale por Microsoft Graph, no por SMTP. Microsoft Graph acepta ese token, comprueba que la identidad tiene autorización, y entrega. Toda la autenticación pasa a ser un problema de la plataforma, y deja de ser un archivo nuestro.
El código
Son menos líneas que la versión con contraseña, y le falta el campo que le interesa a un atacante. DefaultAzureCredential se encarga del token: en la nube usa la identidad gestionada, en nuestra máquina usa la sesión de 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()}`);
}
El mismo código corre en los dos sitios, y en ninguno hay una contraseña escrita. Falta la parte menos obvia: decirle a Exchange que esta identidad solo puede enviar desde un buzón, y desde ningún otro.
Atar el envío a un grupo de buzones
El permiso Mail.Send de Microsoft Graph, concedido solo, deja que la aplicación envíe en nombre de cualquier buzón del tenant, el del administrador incluido. La restricción se hace en Exchange, con RBAC para aplicaciones. Y el alcance no debe ser un buzón fijo, sino un grupo: así añadimos o quitamos buzones después, sin volver a tocar la asignación.
# 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"
Con el grupo creado, registramos la identidad en Exchange, definimos un alcance que resuelve a los miembros del grupo, y concedemos el Mail.Send atado a ese alcance.
# 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"
El AppId es obligatorio, no el ObjectId. Cambiarlos da un 403 «Blocked by tenant configured AppOnly AccessPolicy settings», un error que no dice qué está mal y te manda a buscar en el sitio equivocado.
Las trampas que cuestan toda la seguridad
La primera ya apareció: AppId, nunca ObjectId. La segunda es peor, porque la configuración parece correcta y no lo está. Los permisos de Entra y del RBAC de Exchange se suman. Si la identidad tiene también un Mail.Send concedido en Entra, sin alcance, la unión de los dos anula la restricción, y la aplicación vuelve a poder enviar como cualquier buzón.
La regla es una sola: Mail.Send existe solo en el RBAC de Exchange, restringido al grupo. En Entra, cero. Si el consentimiento está ahí, se quita, o todo el trabajo hecho en Exchange no sirve de nada.
La propagación lleva tiempo
Hecha la configuración, probamos, y falló. Volvimos atrás, revisamos todo, estaba bien, y seguía fallando. El problema no era la configuración: era el tiempo. Los cambios de RBAC en Exchange Online no son inmediatos, se replican por el servicio y pueden tardar hasta media hora en valer en todas partes.
La lección nos costó la mañana: después de tocar el alcance o la asignación, esperar antes de concluir que está mal. El propio Test-ServicePrincipalAuthorization puede responder con el estado antiguo durante esa ventana. Cambiar más cosas en ese intervalo es la mejor manera de romper lo que ya estaba bien.
Cómo se prueba que está cerrado
Una restricción que no se demuestra no cuenta. Exchange responde a la pregunta directamente, buzón a buzón, y esta es la prueba que guardamos.
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
Lo que queda
Una aplicación que envía email, sin ninguna contraseña en ningún sitio, y que solo puede enviar desde el grupo que le dimos. Si se ve comprometida mañana, el atacante no encuentra un secreto que robar, y no escribe en nombre de nadie más. No es un cañón para matar una mosca: es el permiso más pequeño que resuelve el problema, y la prueba de que de verdad es el más pequeño.



