Under the Hood: How VPN Secures Traffic with mTLS
In the world of modern Enterprise Mobility Management (EMM), the “VPN” as we knew it is dead. It has been replaced by Per-App Tunnels that…
Under the Hood: How VPN Secures Traffic with mTLS
In the world of modern Enterprise Mobility Management (EMM), the “VPN” as we knew it is dead. It has been replaced by Per-App Tunnels that provide granular access to internal resources. Workspace ONE Tunnel is a leader in this space, but its security isn’t magic — it relies on a robust implementation of Mutual TLS (mTLS).
If you’ve ever looked at the Tunnel configuration, you’ve seen references to Client Certificates, Server Certificates, and CAs. But how do they actually interact? Does the server ever see your private key? (Spoiler: No).
Let’s break it down technically.
1. The Three Pillars of Trust
To understand the Tunnel, you must understand three types of certificates:
- The Root CA Certificate: This is the “Source of Truth.” Both the device and the Tunnel Server must trust this CA. In WS1, this is often the AirWatch/Workspace ONE Issuing CA.
- The Server Certificate: Installed on the Tunnel Gateway. It proves to the device: “I am actually your corporate gateway, not a hacker’s proxy.”
- The Client Certificate: Delivered to the device via MDM. It proves to the gateway: “I am a managed, compliant device with ID: device-123.”
2. The “Proof of Possession” Secret
A common misconception is that the device “sends” its certificate to the server to log in. While the public certificate is sent, the Private Key never leaves the device.
The server verifies the device owns the private key by sending a “Challenge” (a random string of data). The device signs this data using its private key and sends the signature back. The server uses the public key (from the certificate) to verify the signature. If it matches, the device is authenticated.
3. Simulating the Tunnel Logic in C
To truly understand this, let’s look at a .NET 8 simulation. This code generates a Root CA, issues server/client certs, and performs the cryptographic verification that happens inside the Tunnel Gateway.
The Core Simulation Code
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
// 1. GENERATE THE ROOT CA (The "AirWatch" Authority)
using var rootKey = RSA.Create(3072);
var rootReq = new CertificateRequest("CN=WorkspaceONE-Root-CA", rootKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
rootReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
using var rootCa = rootReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(10));
// 2. ISSUE THE CLIENT CERTIFICATE (The MDM Payload)
using var clientKey = RSA.Create(2048);
var clientReq = new CertificateRequest("CN=device-123-uuid, OU=Managed", clientKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
clientReq.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new("1.3.6.1.5.5.7.3.2") }, true)); // Client Auth EKU
var serial = RandomNumberGenerator.GetBytes(16);
using var clientCertPublicOnly = clientReq.Create(rootCa, DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1), serial);
// 3. THE SERVER-SIDE VERIFICATION (What the Tunnel Gateway does)
// Note: The server ONLY has 'clientCertPublicOnly'. It does NOT have the private key.
Console.WriteLine($"Server received cert for: {clientCertPublicOnly.Subject}");
// SIMULATE HANDSHAKE: Server sends a challenge
byte[] challenge = RandomNumberGenerator.GetBytes(32);
// Client signs it (This happens on the iPhone/Android/Windows device)
byte[] signature = clientKey.SignData(challenge, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
// Server verifies it using ONLY the public key
using var rsaPublicKey = clientCertPublicOnly.GetRSAPublicKey();
bool isLegit = rsaPublicKey.VerifyData(challenge, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
Console.WriteLine(isLegit ? "✅ Access Granted: Device Identity Verified" : "❌ Access Denied: Spoofed Identity");
4. Why this matters for WS1 Admins
When you configure the Tunnel in the UEM Console, you are essentially setting the parameters for the code above:
The EKU (Enhanced Key Usage)
In the code, we added an OID 1.3.6.1.5.5.7.3.2. This is the Client Authentication extension. If a certificate doesn't have this, the Tunnel Gateway will reject it, even if the CA is trusted. This prevents a user from using their "Email Certificate" to gain Tunnel access.
The Subject Name (Mapping)
Notice the CN=device-123-uuid. The Tunnel Gateway doesn't just check if the cert is valid; it extracts this ID to look up the device in the Workspace ONE database. If the device is marked as "Unenrolled" or "Non-Compliant," the Tunnel is torn down instantly.
Chain Building
The X509Chain logic (seen in the full technical implementation) ensures that there are no breaks in the trust. If an attacker tries to use a certificate issued by a "Free CA" or a "Home-made CA," the chain.Build() method returns false, and the connection is dropped.
5. Summary
The Workspace ONE Tunnel is a high-speed application of the mTLS protocol.
- Identity is established by the CA.
- Integrity is established by the EKU and Chain validation.
- Authentication is established by the Proof-of-Possession (signing the challenge).
By understanding the code behind the certificate exchange, you can better troubleshoot “Tunnel Connection Failed” errors — usually, it’s a mismatch in the trust anchor or a missing Client Auth EKU!
Full Code
using System.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
// Demonstration: certificates involved in a typical mTLS setup (similar conceptually to Workspace ONE Tunnel).
// - A *server certificate* identifies the server to the client.
// - A *client certificate* identifies the device/app to the server.
// - Both are usually issued by a CA (enterprise CA or AirWatch/Workspace ONE CA), and trust is established
// by distributing the CA root (or an intermediate) to the validating side.
//
// This sample:
// 1) Creates a demo Root CA.
// 2) Issues a Server cert (DNS=server.example) and a Client cert (CN=device-123).
// 3) Shows how to validate chains.
// 4) Shows how a server would verify the client certificate during a TLS handshake (including proof-of-possession).
//
// Notes for real WS1 Tunnel:
// - The *device* typically receives a client cert (via MDM) into the OS keychain/keystore.
// - The Tunnel *server* presents a server cert to the device.
// - Authentication can be based on client cert presence + chain + EKU + subject/SAN mapping.
// - Private keys never leave the device; the server only sees the public cert.
Console.WriteLine("=== Certificate Learning (.NET 8) ===\n");
var now = DateTimeOffset.UtcNow;
// 1) Create a demo Root CA (self-signed)
using var rootKey = RSA.Create(3072);
var rootReq = new CertificateRequest(
subjectName: new X500DistinguishedName("CN=Demo Root CA, O=CertificateLearning"),
key: rootKey,
hashAlgorithm: HashAlgorithmName.SHA256,
padding: RSASignaturePadding.Pkcs1);
rootReq.CertificateExtensions.Add(
new X509BasicConstraintsExtension(certificateAuthority: true, hasPathLengthConstraint: false, pathLengthConstraint: 0, critical: true));
rootReq.CertificateExtensions.Add(
new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, critical: true));
rootReq.CertificateExtensions.Add(
new X509SubjectKeyIdentifierExtension(rootReq.PublicKey, critical: false));
using var rootCa = rootReq.CreateSelfSigned(now.AddDays(-1), now.AddYears(10));
Console.WriteLine("Created Root CA:");
PrintCert(rootCa);
// 2) Issue a Server certificate
using var serverKey = RSA.Create(2048);
var serverReq = new CertificateRequest(
subjectName: new X500DistinguishedName("CN=server.example, O=CertificateLearning"),
key: serverKey,
hashAlgorithm: HashAlgorithmName.SHA256,
padding: RSASignaturePadding.Pkcs1);
serverReq.CertificateExtensions.Add(
new X509BasicConstraintsExtension(certificateAuthority: false, hasPathLengthConstraint: false, pathLengthConstraint: 0, critical: true));
serverReq.CertificateExtensions.Add(
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, critical: true));
// Enhanced Key Usage: Server Authentication
var serverEku = new OidCollection { new("1.3.6.1.5.5.7.3.1") }; // id-kp-serverAuth
serverReq.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(serverEku, critical: true));
// Subject Alternative Name (SAN) is what modern TLS uses for hostname matching.
var serverSan = new SubjectAlternativeNameBuilder();
serverSan.AddDnsName("server.example");
serverSan.AddIpAddress(IPAddress.Loopback);
serverReq.CertificateExtensions.Add(serverSan.Build());
serverReq.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(serverReq.PublicKey, critical: false));
var serverSerial = RandomNumberGenerator.GetBytes(16);
using var serverCertPublicOnly = serverReq.Create(rootCa, now.AddDays(-1), now.AddYears(2), serverSerial);
using var serverCert = serverCertPublicOnly.CopyWithPrivateKey(serverKey);
Console.WriteLine("\nIssued Server certificate:");
PrintCert(serverCert);
// 3) Issue a Client certificate (this is what MDM would deliver to a device)
using var clientKey = RSA.Create(2048);
var clientReq = new CertificateRequest(
subjectName: new X500DistinguishedName("CN=device-123, OU=ManagedDevices, O=CertificateLearning"),
key: clientKey,
hashAlgorithm: HashAlgorithmName.SHA256,
padding: RSASignaturePadding.Pkcs1);
clientReq.CertificateExtensions.Add(
new X509BasicConstraintsExtension(certificateAuthority: false, hasPathLengthConstraint: false, pathLengthConstraint: 0, critical: true));
clientReq.CertificateExtensions.Add(
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true));
// Enhanced Key Usage: Client Authentication
var clientEku = new OidCollection { new("1.3.6.1.5.5.7.3.2") }; // id-kp-clientAuth
clientReq.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(clientEku, critical: true));
clientReq.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(clientReq.PublicKey, critical: false));
var clientSerial = RandomNumberGenerator.GetBytes(16);
using var clientCertPublicOnly = clientReq.Create(rootCa, now.AddDays(-1), now.AddYears(2), clientSerial);
// On a real device, the cert is paired with a private key in the device keystore.
// We create that pairing here so the *client side* can sign.
using var clientCertWithPrivateKey = clientCertPublicOnly.CopyWithPrivateKey(clientKey);
Console.WriteLine("\nIssued Client certificate (device-side, has private key):");
PrintCert(clientCertWithPrivateKey);
// What the server actually sees: the public certificate only (no private key)
using var presentedClientCert = new X509Certificate2(clientCertPublicOnly.RawData);
Console.WriteLine("\nPresented Client certificate (server-side view, public only):");
PrintCert(presentedClientCert);
// 4) Trust: the validator must trust the CA that issued the certs.
// In enterprises, the Root CA (or intermediate) is distributed to devices and/or servers.
var trustStore = new X509Certificate2Collection { rootCa };
Console.WriteLine("\n=== Chain validation demos ===\n");
Console.WriteLine("Validating SERVER cert as a client would (trusting Demo Root CA) ...");
ValidateChain(serverCert, trustStore, checkEkuOid: "1.3.6.1.5.5.7.3.1");
Console.WriteLine("\nValidating CLIENT cert as a server would (trusting Demo Root CA) ...");
ValidateChain(presentedClientCert, trustStore, checkEkuOid: "1.3.6.1.5.5.7.3.2");
// 5) Simulate server-side client certificate verification logic.
Console.WriteLine("\n=== Simulated mTLS verification (server verifying device cert) ===\n");
// Simulate the TLS "challenge" and the client's signature over it.
// In real TLS, the signed data is derived from the handshake transcript.
var challenge = RandomNumberGenerator.GetBytes(32);
var clientSignature = SignChallenge(clientCertWithPrivateKey, challenge);
SimulateServerVerifyingClientCert(
presentedClientCert: presentedClientCert,
trustedRoots: trustStore,
expectedClientAuthEkuOid: "1.3.6.1.5.5.7.3.2",
expectedDeviceIdPrefixInSubjectCn: "device-",
challenge: challenge,
signature: clientSignature);
Console.WriteLine("\nWhat to try next:");
Console.WriteLine("- Replace clientSignature with random bytes and see proof-of-possession fail.");
Console.WriteLine("- Change the EKU on the client cert (remove clientAuth) and see validation fail.");
Console.WriteLine("- Change the trust store (don’t include the Root CA) and see chain validation fail.");
static void ValidateChain(X509Certificate2 endEntityCert, X509Certificate2Collection trustedRoots, string? checkEkuOid)
{
using var chain = new X509Chain();
// For demo purposes we use custom trust (so we don't need to install the root into OS trust).
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.Clear();
chain.ChainPolicy.CustomTrustStore.AddRange(trustedRoots);
// Typical TLS checks
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; // demo only
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
if (!string.IsNullOrWhiteSpace(checkEkuOid))
{
chain.ChainPolicy.ApplicationPolicy.Add(new Oid(checkEkuOid));
}
var ok = chain.Build(endEntityCert);
Console.WriteLine($"Chain OK: {ok}");
if (!ok)
{
foreach (var status in chain.ChainStatus)
{
Console.WriteLine($" - {status.Status}: {status.StatusInformation.Trim()}");
}
}
}
static byte[] SignChallenge(X509Certificate2 clientCertWithPrivateKey, byte[] challenge)
{
// Client side: signs data using the private key. This private key never leaves the device.
using var rsa = clientCertWithPrivateKey.GetRSAPrivateKey();
if (rsa is null)
{
throw new InvalidOperationException("Client certificate does not have an RSA private key to sign with.");
}
return rsa.SignData(challenge, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
static void SimulateServerVerifyingClientCert(
X509Certificate2 presentedClientCert,
X509Certificate2Collection trustedRoots,
string expectedClientAuthEkuOid,
string expectedDeviceIdPrefixInSubjectCn,
byte[] challenge,
byte[] signature)
{
// Step 1: cryptographic/PKI checks (is it issued by a trusted CA, time-valid, intended for client auth?)
using var chain = new X509Chain();
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.Clear();
chain.ChainPolicy.CustomTrustStore.AddRange(trustedRoots);
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; // demo only
chain.ChainPolicy.ApplicationPolicy.Add(new Oid(expectedClientAuthEkuOid));
var chainOk = chain.Build(presentedClientCert);
if (!chainOk)
{
Console.WriteLine("Rejected: client certificate chain/EKU validation failed.");
foreach (var status in chain.ChainStatus)
{
Console.WriteLine($" - {status.Status}: {status.StatusInformation.Trim()}");
}
return;
}
// Step 2: authorization/mapping checks (does this cert belong to an enrolled device/user?)
// WS1 Tunnel commonly maps based on Subject CN / SAN UPN / device identifiers embedded in cert.
var cn = TryGetCommonName(presentedClientCert.SubjectName);
if (cn is null || !cn.StartsWith(expectedDeviceIdPrefixInSubjectCn, StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine($"Rejected: subject CN '{cn ?? "<missing>"}' doesn't match expected device id pattern '{expectedDeviceIdPrefixInSubjectCn}*'.");
return;
}
// Step 3: proof-of-possession (the TLS handshake proves the client has the private key)
// Server verifies the signature using ONLY the public key from the presented certificate.
using var rsa = presentedClientCert.GetRSAPublicKey();
if (rsa is null)
{
Console.WriteLine("Rejected: presented client certificate does not contain an RSA public key.");
return;
}
var signatureOk = rsa.VerifyData(challenge, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
if (!signatureOk)
{
Console.WriteLine("Rejected: proof-of-possession failed (signature did not verify). Server still never sees the client private key.");
return;
}
Console.WriteLine($"Accepted: client certificate is trusted, mapped to device id '{cn}', and proof-of-possession succeeded.");
}
static string? TryGetCommonName(X500DistinguishedName dn)
{
// Simple CN parser; good enough for demo.
// For production use, prefer a robust DN parser.
var parts = dn.Name?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts is null) return null;
foreach (var part in parts)
{
if (part.StartsWith("CN=", StringComparison.OrdinalIgnoreCase))
return part.Substring("CN=".Length);
}
return null;
}
static void PrintCert(X509Certificate2 cert)
{
Console.WriteLine($" Subject: {cert.Subject}");
Console.WriteLine($" Issuer : {cert.Issuer}");
Console.WriteLine($" NotBefore: {cert.NotBefore:u}");
Console.WriteLine($" NotAfter : {cert.NotAfter:u}");
Console.WriteLine($" HasPrivateKey: {cert.HasPrivateKey}");
var eku = cert.Extensions["2.5.29.37"] as X509EnhancedKeyUsageExtension;
if (eku is not null)
{
Console.WriteLine(" EKU:");
foreach (var oid in eku.EnhancedKeyUsages)
{
Console.WriteLine($" - {oid.Value} ({oid.FriendlyName})");
}
}
}
The “Chain of Trust”: How XYZ gets verified by the Root
Think of certificate verification like a Passport Control desk.
- You show your Passport (XYZ Leaf).
- The officer sees it was signed by State Office (ABC Intermediate).
- The officer doesn’t know the State Office personally, so they check if the State Office was authorized by the Federal Government (Root CA).
- Since the officer has the Federal Government’s seal in their “Trust Store,” the whole chain is accepted.
The Technical “Walk”
Here is how the .NET X509Chain engine performs this logic.
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
internal static class ChainVerificationDemo
{
public static void Main()
{
Console.WriteLine("=== Chain verification demo (Root -> Intermediate -> Leaf) ===\n");
var now = DateTimeOffset.UtcNow;
// Root CA (self-signed)
using var rootKey = RSA.Create(3072);
var rootReq = new CertificateRequest(
new X500DistinguishedName("CN=RootCA, O=CertificateLearning"),
rootKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
rootReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
rootReq.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
rootReq.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(rootReq.PublicKey, false));
using var rootCa = rootReq.CreateSelfSigned(now.AddDays(-1), now.AddYears(15));
// Intermediate CA (signed by root)
using var intermediateKey = RSA.Create(3072);
var intReq = new CertificateRequest(
new X500DistinguishedName("CN=ABC Intermediate CA, O=CertificateLearning"),
intermediateKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
intReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
intReq.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
intReq.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(intReq.PublicKey, false));
var intSerial = RandomNumberGenerator.GetBytes(16);
using var intermediatePublicOnly = intReq.Create(rootCa, now.AddDays(-1), now.AddYears(10), intSerial);
using var intermediateCa = intermediatePublicOnly.CopyWithPrivateKey(intermediateKey);
// Leaf cert (xyz) signed by intermediate
using var leafKey = RSA.Create(2048);
var leafReq = new CertificateRequest(
new X500DistinguishedName("CN=XYZ Leaf, O=CertificateLearning"),
leafKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
leafReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
leafReq.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true));
leafReq.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(leafReq.PublicKey, false));
var leafSerial = RandomNumberGenerator.GetBytes(16);
using var leafPublicOnly = leafReq.Create(intermediateCa, now.AddDays(-1), now.AddYears(2), leafSerial);
using var leaf = leafPublicOnly.CopyWithPrivateKey(leafKey);
Console.WriteLine("Certificates created:");
PrintSummary("Root", rootCa);
PrintSummary("Intermediate", intermediateCa);
PrintSummary("Leaf", leaf);
Console.WriteLine("\nHow chain verification works (high-level):");
Console.WriteLine("1) Start with the leaf (XYZ).");
Console.WriteLine("2) Find its Issuer (ABC Intermediate) and verify: leaf signature checks out using intermediate public key.");
Console.WriteLine("3) Then verify intermediate is issued by RootCA: intermediate signature checks out using root public key.");
Console.WriteLine("4) Finally, trust decision: RootCA must be in your trusted roots store (or OS trust).\n");
Console.WriteLine("Case A: Validate leaf with only Root trusted AND providing Intermediate in ExtraStore (typical TLS chain build)\n");
ValidateAndPrint(
endEntity: leaf,
trustedRoots: new X509Certificate2Collection { rootCa },
extraIntermediates: new X509Certificate2Collection { intermediateCa });
Console.WriteLine("\nCase B: Validate leaf with Root trusted BUT NOT providing Intermediate (expected to fail in this demo)\n");
ValidateAndPrint(
endEntity: leaf,
trustedRoots: new X509Certificate2Collection { rootCa },
extraIntermediates: new X509Certificate2Collection());
Console.WriteLine("\nCase C: Provide Intermediate but DO NOT trust Root (expected to fail: untrusted root)\n");
ValidateAndPrint(
endEntity: leaf,
trustedRoots: new X509Certificate2Collection(),
extraIntermediates: new X509Certificate2Collection { intermediateCa });
Console.WriteLine();
}
private static void ValidateAndPrint(
X509Certificate2 endEntity,
X509Certificate2Collection trustedRoots,
X509Certificate2Collection extraIntermediates)
{
using var chain = new X509Chain();
// This is equivalent to: "I only trust what I explicitly give you as roots"
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.Clear();
chain.ChainPolicy.CustomTrustStore.AddRange(trustedRoots);
// ExtraStore is where you provide intermediates (like the server sending the intermediate cert in TLS).
chain.ChainPolicy.ExtraStore.Clear();
chain.ChainPolicy.ExtraStore.AddRange(extraIntermediates);
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; // demo only
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
var ok = chain.Build(endEntity);
Console.WriteLine($"Chain OK: {ok}");
Console.WriteLine("Chain elements (leaf -> ... -> root):");
foreach (var element in chain.ChainElements)
{
Console.WriteLine($" - {element.Certificate.Subject} | Issuer: {element.Certificate.Issuer}");
}
if (!ok)
{
Console.WriteLine("Chain status:");
foreach (var status in chain.ChainStatus)
{
Console.WriteLine($" - {status.Status}: {status.StatusInformation.Trim()}");
}
}
}
private static void PrintSummary(string label, X509Certificate2 cert)
{
Console.WriteLine($" {label}: Subject='{cert.Subject}', Issuer='{cert.Issuer}', HasPrivateKey={cert.HasPrivateKey}");
}
}
3 Key Takeaways for WS1 Tunnel Admins
Based on the code above, here is why your Tunnel might be failing even if “the certificate looks fine”:
- The
ExtraStore(Intermediates): In Workspace ONE, if your Tunnel Server doesn't include the Intermediate CA in its SSL configuration, mobile devices will fail to connect (Case 2). Android/iOS are much stricter about "Chain Completeness" than a desktop browser might be. - Custom Root Trust: Devices get the Root CA via an MDM Profile. If that profile isn’t installed, the device has no “Trust Anchor” (Case 3), and the tunnel cannot be established.
- Basic Constraints: In the code,
intermediateCahascertificateAuthority: true. If you accidentally issue an Intermediate CA without this flag, the chain building will fail because the leaf certificate wasn't signed by a "valid" authority.
4 Putting it All together
Since we’ve been geeking out over how certificates are verified and used for the Workspace ONE (WS1) Tunnel, SCEP (Simple Certificate Enrollment Protocol) is the missing piece of the puzzle: it’s the “delivery truck” that gets the certificate onto the device in the first place.
In the old days of IT, if you wanted a certificate on a device, you had to manually generate a request, go to a web portal, download the file, and install it. SCEP automates this entire process without the private key ever leaving the device.
The SCEP Workflow (The 4-Step Handshake)
In a Workspace ONE environment, SCEP usually involves the Device, the WS1 UEM console, and an Enterprise CA (like Microsoft ADCS).
- The Profile Push: Workspace ONE sends a “SCEP Profile” to the device. This profile contains the URL of the SCEP server and a unique Challenge Password (a one-time-use secret).
- Key Generation: The device generates a Private/Public Key pair locally in its secure hardware (like the TPM or Secure Enclave). The private key stays there.
- The CSR (Request): The device creates a Certificate Signing Request (CSR). It bundles its Public Key + the Challenge Password and sends it to the SCEP URL.
- The Issuance: The SCEP server validates the challenge password with Workspace ONE. If it’s legit, it forwards the request to the CA, gets the certificate signed, and sends it back to the device.
3. Why SCEP is Critical for WS1 Tunnel
You might wonder: “Why not just send a PFX file directly to the device?”
- Security (Private Key Isolation): If Workspace ONE sends you a PFX, the private key exists on the WS1 server and is sent over the air. With SCEP, the private key is born on the device and never exists anywhere else. Even the WS1 admin can’t steal it.
- Automation: When a certificate is about to expire, the device can automatically trigger a new SCEP request without user intervention.
- Unique Identity: Every device gets a unique certificate. This allows you to revoke access for “Phone A” while “Phone B” remains connected to the Tunnel.
If we look at the lifecycle of a managed device, it follows this exact flow:
- Enrollment (SCEP): The device is born into the network. It creates its own private key and uses SCEP to get a “ID Card” (Certificate) from the CA.
- Trust (Chain Building): When the device tries to talk to the Tunnel, the Gateway checks that “ID Card.” It traces the signature from the Leaf to the Intermediate, and finally to the Root.
- Authentication (mTLS): The Gateway challenges the device to prove it owns that ID card by signing a piece of data.
Conclusion: Why This Architecture Wins
By combining SCEP, Chain Verification, and mTLS, VMware Workspace ONE ensures three things that a standard “Username/Password” VPN cannot:
- Non-Exportability: Because of SCEP, the private key is trapped in the device’s hardware. A user cannot “copy” their VPN credentials to a personal, unmanaged laptop.
- Instant Revocation: If a device is lost, the admin revokes the certificate at the CA. The next time the device tries the mTLS handshake, the
X509Chainvalidation will fail the CRL (Certificate Revocation List) check. - Zero User Friction: The user doesn’t type a password. The Tunnel app simply uses the SCEP-delivered certificate to perform the handshake in the background.
Simulating a SCEP Request in C
While a real SCEP request happens over HTTP with heavy CMS (Cryptographic Message Syntax) encoding, we can simulate the “Device Request” part in .NET. This shows how a device asks for a cert using a “secret” challenge.
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
internal static class ScepSimulationDemo
{
public static void Run()
{
Console.WriteLine("=== SCEP Enrollment Simulation ===\n");
// 1. DEVICE SIDE: Generate local keys (Private key never leaves!)
using var deviceKey = RSA.Create(2048);
// 2. DEVICE SIDE: Create a CSR with a "Challenge Password"
// In SCEP, the Challenge Password is often stored in an attribute (OID 1.2.840.113549.1.9.7)
var challengePassword = "WS1-One-Time-Secret-123";
var request = new CertificateRequest(
"CN=Managed-Device-001",
deviceKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
Console.WriteLine($"[Device] Generated CSR for {request.SubjectName.Name}");
Console.WriteLine($"[Device] Attaching Challenge Password: {challengePassword}");
// 3. SERVER SIDE: The CA/SCEP Server receives the CSR
// This is where the CA verifies the secret before signing.
Console.WriteLine("\n[CA Server] Verifying Challenge Password...");
if (challengePassword == "WS1-One-Time-Secret-123")
{
Console.WriteLine("[CA Server] Challenge Valid! Issuing Certificate...");
// Simulating CA signing the request
using var caKey = RSA.Create(3072);
var caReq = new CertificateRequest("CN=Enterprise-Issuing-CA", caKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
using var caCert = caReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(5));
var serial = RandomNumberGenerator.GetBytes(16);
using var issuedCert = request.Create(caCert, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1), serial);
Console.WriteLine($"\n[Device] Received Issued Certificate!");
Console.WriteLine($"Subject: {issuedCert.Subject}");
Console.WriteLine($"Thumbprint: {issuedCert.Thumbprint}");
}
else
{
Console.WriteLine("[CA Server] REJECTED: Invalid Challenge Password.");
}
}
} 메타데이터
- post_id
- b8b5239d3442
- slug
- under-the-hood-how-vmware-workspace-one-tunnel-secures-traffic-with-mtls-b8b5239d3442
- url
- https://medium.com/@sumit-s/under-the-hood-how-vmware-workspace-one-tunnel-secures-traffic-with-mtls-b8b5239d3442
- canonical_url
- https://medium.com/@sumit-s/under-the-hood-how-vmware-workspace-one-tunnel-secures-traffic-with-mtls-b8b5239d3442
- author_url
- https://medium.com/@sumit-s
- status
- ok
- fetched_at
- 2026-07-14 22:43:35