Solución TLS 1.2 al error “The underlying connection was closed” en PowerShell con Microsoft Graph

Si tu script de PowerShell que llama a Microsoft Graph funciona en la red interna pero falla en la DMZ con el mensaje “The underlying connection was closed: An unexpected error occurred on a send”, casi siempre se trata de un problema de negociación TLS. Aquí tienes el diagnóstico y la solución, con enfoque seguro en TLS 1.2.

Índice

Resumen del caso

Un script de PowerShell que envía correos mediante Microsoft Graph funciona en equipos internos pero falla en un servidor ubicado en la DMZ. Las pruebas de conectividad básicas “pasan” (se ve el puerto 443 abierto), pero al ejecutar la llamada real a Graph aparece el error:

The underlying connection was closed: An unexpected error occurred on a send.

En los registros del firewall perimetral se observa tcp-rst-from-client, lo que sugiere que el handshake TLS no concluye correctamente. En pocas palabras: hay sesión TCP pero no se establece el canal seguro.

Solución inmediata y segura

Habilita explícitamente TLS desde el proceso de PowerShell antes de invocar Invoke-RestMethod / Invoke-WebRequest (o cualquier cliente HTTP que uses). Coloca una de estas líneas al inicio del script o justo antes de la llamada a Graph:

Opción recomendada

Fuerza solo TLS 1.2 (y 1.3 si tu runtime lo soporta). Es la práctica moderna y segura.

# TLS 1.2 recomendado (Windows PowerShell 5.1 / .NET Framework)
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

Opción amplia de compatibilidad

Úsala solo si necesitas convivir con endpoints antiguos durante una migración controlada:

# Permite TLS 1.0/1.1/1.2 en el proceso
[Net.ServicePointManager]::SecurityProtocol =
    [Net.SecurityProtocolType]::Tls   -bor
    [Net.SecurityProtocolType]::Tls11 -bor
    [Net.SecurityProtocolType]::Tls12

Tras aplicar el ajuste anterior, el envío vía Microsoft Graph suele volver a funcionar en el servidor de la DMZ.

Nota: PowerShell 7+ (Core) usa por defecto TLS 1.2+ y normalmente no requiere esta línea. En Windows PowerShell 5.1 sí puede ser necesario. Si usas PS 7+ y un HttpClient propio, asegúrate de no forzar protocolos más antiguos en el handler.

Por qué ocurre

Los servidores perimetrales y equipos en DMZ a menudo tienen configuraciones de seguridad endurecidas o, por el contrario, heredadas. En Windows PowerShell 5.1 (sobre .NET Framework), el valor por defecto de SecurityProtocol puede permitir negociar versiones obsoletas (TLS 1.0/1.1) o no incluir TLS 1.2 según el nivel de .NET y las claves de registro. Microsoft Graph exige TLS moderno; cuando el cliente intenta negociar con una versión o conjunto de cifrados no aceptados, el servidor cierra la conexión durante el handshake. En los registros de la infraestructura de red esto suele aparecer como tcp-rst-from-client y en .NET como “underlying connection was closed”.

Diagnóstico rápido

Antes de abrir tickets, confirma con pruebas sencillas si el problema está en la capa TLS.

Comprobación dentro del proceso

# Observa qué protocolos admite actualmente el proceso
[Net.ServicePointManager]::SecurityProtocol

Si Tls12 no aparece, debes forzarlo (ver solución).

Conectividad TCP vs. HTTP/TLS

# Alcance TCP al 443 (no valida TLS)
Test-NetConnection graph.microsoft.com -Port 443

Validación HTTP/TLS:

En Windows PowerShell 5.1

Invoke-WebRequest [https://graph.microsoft.com/v1.0/\$metadata](https://graph.microsoft.com/v1.0/$metadata) -UseBasicParsing

En PowerShell 7+ (sin UseBasicParsing)

Invoke-WebRequest [https://graph.microsoft.com/v1.0/\$metadata](https://graph.microsoft.com/v1.0/$metadata) 
  • Si Test-NetConnection pasa pero Invoke-WebRequest falla, el problema es el handshake TLS/cifrados, no el firewall de capa 3/4.
  • Si ambos fallan, podría existir bloqueo de red o resolución DNS incorrecta.

Versiones de PowerShell y .NET

$PSVersionTable
Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' | 
  Select-Object Release, Version

Actualiza a .NET 4.8+ y considera PowerShell 7.x en servidores nuevos.

Política TLS del sistema (Schannel)

Asegura que TLS 1.2 esté habilitado a nivel del sistema operativo (útil para que todos los procesos negocien de forma segura sin tocar cada script). Comprueba estas claves:

# TLS 1.2 habilitado para cliente
Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client'

.NET: uso de cifrados fuertes / versiones por defecto del sistema

Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft.NETFramework\v4.0.30319' |
Select-Object SchUseStrongCrypto, SystemDefaultTlsVersions 

Dispositivos intermedios

Si existe proxy o inspección TLS, excluye los dominios de Microsoft Graph y de autenticación de Azure AD para evitar rupturas de handshake. Asegúrate de que el dispositivo soporte TLS 1.2 y los cipher suites modernos. Dominios típicos a revisar: graph.microsoft.com, login.microsoftonline.com, login.microsoft.com, sts.windows.net, aadcdn.msftauth.net, aadcdn.msauth.net.

Prueba dirigida en la DMZ

Ejecuta el script con logging detallado justo después de establecer SecurityProtocol. Si el error cambia de “connection was closed” a un código HTTP de Graph (por ejemplo 401/403/400), ya superaste la barrera TLS y puedes seguir depurando autenticación o permisos.

Plantilla de script robusto

Este ejemplo contempla buenas prácticas: establece TLS 1.2 en Windows PowerShell 5.1, obtiene un token con client credentials y envía un correo con Graph usando /sendMail. Ajusta variables y permisos de la app en Azure AD según tu entorno.

#requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

--- TLS seguro (necesario en Windows PowerShell 5.1 / .NET Framework) ---

try {
\[void]\[Net.ServicePointManager]::SecurityProtocol
\[Net.ServicePointManager]::SecurityProtocol = \[Net.SecurityProtocolType]::Tls12
} catch { }

--- Parámetros (ajusta a tu entorno) ---

\$TenantId    = '\'
\$ClientId    = '\'
\$ClientSecret = '\'   # Usa KeyVault si es posible
\$GraphScope  = '[https://graph.microsoft.com/.default](https://graph.microsoft.com/.default)'
\$FromUser    = '[remitente@tu-dominio.com](mailto:remitente@tu-dominio.com)'     # cuenta habilitada para enviar
\$ToUser      = '[destinatario@tu-dominio.com](mailto:destinatario@tu-dominio.com)'

--- Función: Obtener token app-only (client credentials) ---

function Get-GraphToken {
param(
\[Parameter(Mandatory)] \[string]\$TenantId,
\[Parameter(Mandatory)] \[string]\$ClientId,
\[Parameter(Mandatory)] \[string]\$ClientSecret,
\[Parameter(Mandatory)] \[string]\$Scope
)
\$tokenEndpoint = "[https://login.microsoftonline.com/\$TenantId/oauth2/v2.0/token](https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token)"
\$body = @{
client\_id     = \$ClientId
scope         = \$Scope
client\_secret = \$ClientSecret
grant\type    = 'client\credentials'
}
\$resp = Invoke-RestMethod -Method Post -Uri \$tokenEndpoint -Body \$body -ContentType 'application/x-www-form-urlencoded'
return \$resp.access\_token
}

--- Función: Enviar correo con Graph /sendMail ---

function Send-GraphMail {
param(
\[Parameter(Mandatory)] \[string]\$AccessToken,
\[Parameter(Mandatory)] \[string]\$FromUser,
\[Parameter(Mandatory)] \[string]\$ToUser
)
\$uri = "[https://graph.microsoft.com/v1.0/users/\$FromUser/sendMail](https://graph.microsoft.com/v1.0/users/$FromUser/sendMail)"
\$payload = @{
message = @{
subject = 'Prueba desde DMZ con TLS 1.2'
body    = @{
contentType = 'HTML'
content     = 'Hola, este es un correo enviado desde un servidor en la DMZ usando Graph.'
}
toRecipients = @(
@{ emailAddress = @{ address = \$ToUser } }
)
}
saveToSentItems = \$true
}
\$headers = @{ Authorization = "Bearer \$AccessToken" }
Invoke-RestMethod -Method Post -Uri \$uri -Headers \$headers -Body (\$payload | ConvertTo-Json -Depth 10) -ContentType 'application/json'
}

--- Ejecución ---

try {
Write-Host 'Obteniendo token...'
\$token = Get-GraphToken -TenantId \$TenantId -ClientId \$ClientId -ClientSecret \$ClientSecret -Scope \$GraphScope
Write-Host 'Enviando correo con Graph...'
Send-GraphMail -AccessToken \$token -FromUser \$FromUser -ToUser \$ToUser
Write-Host 'Envío completado.'
}
catch \[System.Net.WebException] {
Write-Warning "WebException: \$(\$*.Exception.Message)"
if (\$*.Exception.Response) {
\$stream = \$*.Exception.Response.GetResponseStream()
\$reader = New-Object System.IO.StreamReader(\$stream)
\$body   = \$reader.ReadToEnd()
Write-Host "Respuesta HTTP: \$body"
}
if (\$*.Exception.InnerException) {
Write-Host "Detalle interno: \$(\$*.Exception.InnerException.Message)"
}
throw
}
catch {
Write-Error \$*
throw
} 

Si ves respuesta HTTP de Graph (por ejemplo 202/204) en lugar del error de transporte, tu capa TLS está correcta. Si aparece 401/403, revisa permisos de la app, consentimiento, roles y ámbito.

Tabla rápida de síntomas y acciones

SíntomaInterpretación probableAcción recomendada
Test-NetConnection OK, Invoke-WebRequest fallaHandshake TLS o cifrados incompatiblesForzar TLS 1.2 en el proceso; validar Schannel y ciphers; actualizar .NET/PS
Firewall muestra tcp-rst-from-clientEndpoint o dispositivo intermedio aborta handshakeExcluir inspección TLS; verificar compatibilidad TLS 1.2 en proxy; revisar cadena de certificados
Error “Could not create SSL/TLS secure channel”Cliente no logra negociar protocolo/cipher suitHabilitar TLS 1.2 y cifrados modernos; comprobar GPO/registro
Funciona en LAN, falla en DMZPolíticas diferentes (GPO/Schannel/proxy) por segmentoAlinear políticas; aplicar SystemDefaultTlsVersions y SchUseStrongCrypto
PS 7+ OK; Windows PowerShell 5.1 fallaDiferencia de defaults TLS entre runtimesAgregar línea de ServicePointManager en 5.1 o migrar a PS 7+

Endurecimiento a nivel sistema

Para evitar depender de cada script, establece TLS 1.2+ como predeterminado con GPO o automatización. Ejemplos de claves:

Windows Registry Editor Version 5.00

; Habilitar TLS 1.2 para cliente (Schannel)
\[HKEY\LOCAL\MACHINE\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client]
"DisabledByDefault"=dword:00000000
"Enabled"=dword:00000001

; Forzar .NET a usar cifrados fuertes y versiones del sistema
\[HKEY\LOCAL\MACHINE\SOFTWARE\Microsoft.NETFramework\v4.0.30319]
"SchUseStrongCrypto"=dword:00000001
"SystemDefaultTlsVersions"=dword:00000001

\[HKEY\LOCAL\MACHINE\SOFTWARE\WOW6432Node\Microsoft.NETFramework\v4.0.30319]
"SchUseStrongCrypto"=dword:00000001
"SystemDefaultTlsVersions"=dword:00000001 

Tras aplicarlo, reinicia los servicios o el servidor para que Schannel tome la configuración. Idealmente deshabilita TLS 1.0/1.1 en todo el entorno salvo que una dependencia legacy lo impida.

Revisión de cipher suites

En Windows Server 2016+ puedes listar los cipher suites locales y confirmar que incluyes suites modernas (por ejemplo ECDHE con AES-GCM):

Get-TlsCipherSuite | Sort-Object -Property Name | Format-Table Name, Protocols

Si solo ves suites antiguas o CBC, alinea el orden y el set con estándares actuales. En entornos inspeccionados, asegúrate de que el proxy negocie también suites modernas hacia Internet.

Pruebas adicionales útiles

  • curl nativo (si está disponible): curl.exe -v https://graph.microsoft.com/v1.0/$metadata. Verás detalles de TLS negociado.
  • Eventos Schannel: en el Visor de eventos → Registros de Windows → Sistema → origen Schannel, busca 36874/36888/36886 para pistas de fallas en handshake.
  • Captura controlada: netsh trace start capture=yes persistent=no y netsh trace stop para analizar a posteriori.

Buenas prácticas operativas

  • Mantén actualizado el runtime: PowerShell 7.x y .NET 6/8 ofrecen defaults seguros y mejor diagnóstico.
  • Centraliza políticas TLS con GPO para evitar “parches” por script.
  • Evita reabrir TLS 1.0/1.1. Si necesitas compatibilidad temporal, delimítala al proceso y documenta fecha de retiro.
  • Si usas inspección TLS, documenta exclusiones para dominios críticos de identidad y Graph.
  • Revisa periódicamente los cipher suites permitidos y el orden de preferencia.

Checklist de resolución y verificación

  1. En Windows PowerShell 5.1, añade [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 y repite la llamada.
  2. Si sigue fallando, valida que el sistema tenga TLS 1.2 habilitado en Schannel y .NET use cifrados fuertes (SchUseStrongCrypto).
  3. Prueba Invoke-WebRequest https://graph.microsoft.com/v1.0/$metadata: si responde 200, TLS está bien.
  4. Comprueba permisos de la app en Graph si obtienes 401/403.
  5. Si hay proxy/inspección, excluye dominios de Graph y autenticación, y confirma soporte de TLS 1.2.
  6. Repite la prueba de envío con /sendMail y registra el código HTTP devuelto.

Preguntas frecuentes

¿Por qué en la LAN funciona y en la DMZ no? Políticas distintas: Schannel, GPO, proxies o inspección TLS. En LAN puedes tener TLS 1.2 habilitado por defecto y en DMZ no.

¿Es seguro permitir TLS 1.0/1.1 temporalmente? No se recomienda. Úsalo solo como último recurso y restringido al proceso mientras migra la dependencia afectada.

¿PowerShell 7+ soluciona esto solo? En la mayoría de casos sí, porque adopta TLS 1.2+ por defecto. Aun así, si el proxy o el sistema bloquea TLS 1.2, seguirá fallando.

¿Puedo establecer el protocolo desde Invoke-WebRequest? No directamente en Windows PowerShell 5.1. La vía habitual es ServicePointManager. En PS 7+ el stack usa la configuración del sistema.

¿Qué significa exactamente tcp-rst-from-client? Que el extremo “cliente” (o un dispositivo intermedio que actúa como tal) envió un RST para terminar la conexión, a menudo por fallo en negociación TLS o política del proxy.

Caso cerrado

El incidente se resolvió habilitando explícitamente TLS 1.2 en el proceso de PowerShell antes de realizar las llamadas HTTP a Graph. Con ese ajuste, el servidor en la DMZ completó el handshake, desapareció el “underlying connection was closed” y el envío de correos mediante Graph API quedó operativo. El siguiente paso recomendable es aplicar la política a nivel de sistema/GPO para que todos los procesos negocien de forma segura sin depender de cada script.

Anexo de comandos útiles

# Ver protocolos activos en el proceso
[Net.ServicePointManager]::SecurityProtocol

Conectividad TCP

Test-NetConnection graph.microsoft.com -Port 443

Petición simple a Graph para validar TLS/HTTP

(en Windows PowerShell 5.1)

Invoke-WebRequest [https://graph.microsoft.com/v1.0/\$metadata](https://graph.microsoft.com/v1.0/$metadata) -UseBasicParsing

(en PowerShell 7+)

Invoke-WebRequest [https://graph.microsoft.com/v1.0/\$metadata](https://graph.microsoft.com/v1.0/$metadata)

Listar cipher suites (Server 2016+)

Get-TlsCipherSuite | Sort-Object Name | Format-Table Name, Protocols

Revisar .NET Framework instalado

Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' |
Select-Object Release, Version 

Resumen para llevar

  • El error no es “de Graph”, es de transporte TLS en el cliente o en el camino.
  • Forzar TLS 1.2 en Windows PowerShell 5.1 suele resolver de inmediato.
  • Mejor práctica: habilitar TLS 1.2+ por defecto a nivel de sistema y migrar a PowerShell 7+.
  • Si hay proxy/inspección TLS, aplica exclusiones para Graph y dominios de identidad de Microsoft.

Resultado en este caso: habilitar TLS 1.2 desde el script eliminó el error de conexión y permitió enviar correos vía Graph API desde el servidor en la DMZ.

Índice