Create Self-Singed Certificate with trusted chain.

Quang PhanQuang Phan
9 min read

Most of us have created a single self-signed certificate to test SSL/TLS solutions in our local environment. However, I’d like to focus on creating a self-signed certificate with a trusted chain, as this more closely reflects the type of certificate we use when purchasing from trusted certificate authorities (CA).

What is a self signed certificate with a trusted chain? It means that the certificate is “signed” by an intermediate certificate or directly by a trusted certificate authority (CA), establishing a chain of trust.

An example of a certificate with a trusted chain is when you open a certificate you would see a tree like structure under the Certification Path.

What does this all mean? a Certificate Authority (CA) is like the Vatican in the Catholic Church. Anyone can claim to be a religious leader or start their own church (just as anyone can create a self-signed certificate or declare themselves a CA). However, the Vatican is widely trusted and recognized because it has a long history, established traditions, and is accepted by millions as the central authority for Catholicism.

Similarly, a trusted CA (like DigiCert, GlobalSign, or Let’s Encrypt) is recognized by browsers and operating systems because it has a proven track record, follows strict security practices, and is included in the trusted root store. When the Vatican declares someone a saint, Catholics around the world accept it; when a trusted CA signs a certificate, computers and browsers around the world accept it as valid.

If you declare yourself a CA, it’s like starting your own church—people (and browsers) won’t trust you unless you have that established reputation and are included in their list of trusted authorities. Trust is built over time, through recognized authority and adherence to accepted standards.

Expanding the analogy further: when the Vatican (or the Pope) ordains someone as a priest, it is vouching for that person’s legitimacy and authority within the Church. In the same way, when a CA issues and signs a certificate, it is vouching for the identity and legitimacy of the certificate holder. Just as Catholics trust an ordained priest because the Vatican stands behind them, computers and browsers trust a certificate because a recognized CA stands behind it. If someone claims to be a priest without Vatican ordination, their legitimacy is questioned—just as a self-signed certificate is not trusted by browsers.

In order to create a self signed certificated with trusted chain we must follow this order of steps:

  1. Create a Root Certificate Authority certificate.

  2. Create a certificate which is signed by the above Root certificate.

  3. Export the Root CA’s public certificate.

  4. Add the CA public certificate to the trusted store.

Create Root CA certificate:

$certName = "ServerCert"

#Create a Root CA Certificate
$rootCert = New-SelfSignedCertificate `
    -Type Custom `
    -KeyUsage CertSign, CRLSign, DigitalSignature `
    -Subject "CN=${certName}CA" `
    -KeyAlgorithm RSA `
    -KeyLength 2048 `
    -HashAlgorithm sha256 `
    -CertStoreLocation "Cert:\LocalMachine\My" `
    -KeyExportPolicy Exportable `
    -NotAfter (Get-Date).AddYears(10) `
    -TextExtension @("2.5.29.19={critical}{text}CA=true")   # CA certificate

This script create a certificate but what makes it a CA is in the TextExtension . It dictates what this certificate can be used for and in our case it is meant to be a Root Certificate because of CA=true portion.

Create Server Certificate signed by Root Certificate:

# Create server certificate and have it signed by the root CA
# This will create a certificate chain where the server certificate is signed by the root CA.
$serverCertKey = New-SelfSignedCertificate `
    -DnsName "localhost", "127.0.0.1", "*.localhost", `
    -CertStoreLocation "Cert:\LocalMachine\My" `
    -KeyExportPolicy Exportable `
    -KeyUsage DigitalSignature, KeyEncipherment `
    -Type Custom `
    -Subject "CN=${certName}" `
    -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.1") ` #Server Authentication
    -NotAfter (Get-Date).AddYears(1) `
    -KeyAlgorithm RSA `
    -HashAlgorithm sha256 `
    -FriendlyName "${certName}" `
    -Signer $rootCert  # This line creates the chain by signing with root CA

The command is very similar to what we use to create the Root certificate (CA) but the parameter changes the properties of this certificate. Let’s go into some of the more relevant properties.

  • DNSName - This certificate is created so that it only works with application that has url endpoints on localhost, 127.0.01 or a wildcard localhost url such as test.localhost.

  • TexExtension - Which functionality this certificated can be used for and in this case it is for Server Authentication (1.3.6.1.5.5.7.3.1).

  • Signer - We explicitly point this signer to the Root CA certificate which we just created above.

This command creates a certificate that contains both a public key and a private key. The private key is essential for operations such as encryption, signing, and verification. But where is this private key stored? By default, Windows securely stores the private key within the operating system, and access to it is managed through Windows authentication and permissions.

As a result, only users or processes with the appropriate permissions can access and use the private key. For example, if your application runs under a specific user account—such as SYSTEM or a custom service identity—that account must have sufficient permissions to access the private key. Without the correct permissions, your application will not be able to use the certificate for signing, encryption, or decryption operations.

At this point, this certificate is ready to be used by an application for SSL/TLS communication, that can be done in our application by telling it to use this recently created certificate using its thumbprint value.

// Set the HTTPS URL before building the app
builder.WebHost.UseSetting("urls", $"https://localhost:3001");

var serverCertificate = GetServerCertificateByThumbprint("f3780c36d4653950b678ef180029f0cb1fba82de");
builder.Services.Configure<KestrelServerOptions>(options =>
{
    options.ConfigureHttpsDefaults(httpsOptions =>
    {
        httpsOptions.ServerCertificate = serverCertificate;
    });
});

If you browse to its swagger page at https://localhost:3001/swagger you would see that common privacy error that often comes with Self Signed certificate:

Looking at the error you could tell the reason too: net::ERR_CERT_AUTHORITY_INVALID. Basically the browser in this case is telling us that it does not trust this certificate because it does not know or trust the Certificate Authority - ServerCertCA which is used to sign our certificate even though our certificate works and we can encrypt data for the communication. In order to correct this we’ll do the next step:

Export the Root CA’s public certificate”

Just like our server certificate, the Root certificate also contains both a public key and a private key. At this stage, we want to provide the public key of our Root certificate to the client so they can explicitly trust our certificate.

# Export root CA public certificate (public only) 
Export-Certificate -Cert $rootCert -FilePath "${certPath}\${certName}RootCA.cer"

Import CA public certificate to the trusted store.

Typically, the Root CA would reside on a separate machine, but since we are developing locally, it happens to be on the same server as our application. With self-signed certificates, the general rule is that we must explicitly trust them. We accomplish this by adding the certificate to the Trusted Root Certification Authorities store.

# Import CA into Windows Trusted Root to trust this CA certificate
# This is necessary for the server certificate to be trusted by clients.
Import-Certificate -FilePath "${certPath}\${certName}RootCA.cer" -CertStoreLocation "Cert:\LocalMachine\Root"

At this point you may have question why we only import the CA Root certificate to the trusted store and not the server certificate?

You don’t need to add the self-signed server certificate itself to the trusted store. Instead, trust is established by the Root Certificate Authority (CA) that issued the server certificate.

In a typical public key infrastructure, the server certificate is signed by a Root CA, which acts as the anchor of trust. When a client connects to our server, it receives the server certificate along with the entire certificate chain leading up to the Root CA.

The client then checks whether the Root CA’s public certificate is present in its trusted root store. If the Root CA is trusted and the certificate chain is valid, the client will automatically trust the server certificate—even if it was self-signed by your own CA—because the chain leads back to a trusted root.

By adding only the Root CA’s public certificate to the trusted store, we ensure that all certificates signed by that CA are automatically trusted by clients. This approach is scalable and mirrors how trust is established with commercial certificate authorities, where the root of trust is always the CA, not the individual server certificates.

Now when looking for the Root CA certificate, you’d see it is now in the machine’s Trusted Root store.

Once that is done, now we can run our application and now browsing to the same URL will show the certificate is fully trusted by your local browser.

The Got Ya list:

From a personal experience, self signed certificate failure sometime can be confusing because what trigger the failure may not always be easily presented, here are some of the common one I came across.

Lack of Permissions:

When our application runs under a specific user identity that lacks the necessary permissions, you may encounter errors that can be misleading. For example, when we run the application using dotnet run in a PowerShell window without administrative rights, the application starts successfully. However, when we try to access https://localhost:3001/swagger, Kestrel reports an error stating it cannot find the private key for the certificate we want to use. If we run the application again in a PowerShell session with administrative privileges, the error disappears, confirming that the issue was due to insufficient permissions to access the certificate’s private key.

Revocation Check Failure:

Earlier in this blog we can clearly see our server certificate is trusted by the browser and everything looks good, but let’s put it under further examination by running this script to check if it is indeed valid. Using the exported public certificate I will attempt to check if it is valid using the Powershell script below:

# Load the certificate from a file (or use the certificate store)
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 "C:\certificates\ServerCert.cer"

# Create a new X509Chain object
$chain = New-Object System.Security.Cryptography.X509Certificates.X509Chain

# (Optional) Set chain policy, e.g., revocation check
$chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::Online

# Build the chain and verify
$isValid = $chain.Build($cert)

# Output result
if ($isValid) {
    Write-Host "Certificate is valid."
} else {
    Write-Host "Certificate is NOT valid."
    foreach ($status in $chain.ChainStatus) {
        Write-Host $status.Status, $status.StatusInformation
    }
}

The output shows this certificate is in fact not valid in certain sense

The failure is due to RevocationMode, it tells Windows to check, over the internet, whether any certificate in the chain (including the root or any intermediates) has been revoked by the authority that issued it. This is done by contacting a special server (using CRL or OCSP) to see if the certificate has been reported as compromised or invalid.

For self-signed certificates, this check almost always fails. That’s because self-signed certificates (and most test root CAs you create yourself) do not publish revocation information online—there’s no public server for Windows to contact. As a result, when you set RevocationMode to Online, Windows tries to check for revocation, can’t find any information, and may mark the certificate as invalid or untrusted.

Now, if we change in the script to NoCheck then our certificate now is considered valid:

In many server environments, such as .NET, the default value for this flag is set to Online. This means the system will attempt to check the revocation status of certificates over the internet. If you are working in a local development environment, be aware of this setting. To avoid revocation check errors during development, it’s a good idea to set the revocation mode to NoCheck when running in debug mode. This allows your application to bypass revocation checks, which are not typically relevant for self-signed or locally issued certificates.

#if DEBUG
                    options.RevocationMode = X509RevocationMode.NoCheck;
#endif

Technically, it is possible to configure a local Certificate Revocation List (CRL) or OCSP responder so that even with Online mode enabled, the revocation check would succeed. However, setting up local revocation infrastructure is a more advanced topic and is generally unnecessary for most development scenarios.

Happy Reading !!!

GitHub code reference: https://github.com/amphan613/mTLS/commit/9ce4980a2e5d3c0fb6a6bccc62ca6572f2502a98

0
Subscribe to my newsletter

Read articles from Quang Phan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Quang Phan
Quang Phan