Client Certificate

Authenticate requests using client TLS certificates (mTLS).

Full source

File: pwsh/tutorial/examples/8.8-Client-Certificate-Authentication.ps1

<#
    Sample: Client Certificate Authentication
    Purpose: Demonstrates client certificate (mTLS) authentication with X.509 certificate validation.
    File:    8.8-Client-Certificate-Authentication.ps1
    Notes:   Uses self-signed certificates for testing. For production, use CA-signed certificates.
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '')]
param(
    [int]$Port = 5000,
    [IPAddress]$IPAddress = [IPAddress]::Loopback
)

# 1. (Optional) Logging pipeline
New-KrLogger |
    Set-KrLoggerLevel -Value Debug |
    Add-KrSinkConsole |
    Register-KrLogger -Name 'console' -SetAsDefault | Out-Null

# 2. Create server host
New-KrServer -Name 'Client Certificate Auth Demo'
# Set certs directory
$certPath = Join-Path -Path $PSScriptRoot -ChildPath 'certs'
$basename = [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath)

# 3. Create or load server certificate for HTTPS
$serverCertPath = Join-Path -Path $certPath -ChildPath "$basename-server-cert.pfx"
if (Test-Path $serverCertPath) {
    $serverCert = Import-KrCertificate -FilePath $serverCertPath -Password (ConvertTo-SecureString -String 'test' -AsPlainText -Force)
} else {
    if (-not (Test-Path -Path $certPath  )) {
        New-Item -ItemType Directory -Path $certPath | Out-Null
    }
    # Create server certificate for localhost
    $serverCert = New-KrSelfSignedCertificate -DnsNames 'localhost' -Exportable

    # Export for reuse
    Export-KrCertificate -Certificate $serverCert -FilePath $serverCertPath `
        -Format pfx -IncludePrivateKey -Password (ConvertTo-SecureString -String 'test' -AsPlainText -Force)

    Write-Host "Created server certificate: $serverCertPath"
}

# 4. Create or load client certificate for testing
$clientCertPath = Join-Path -Path $certPath -ChildPath "$basename-client-cert.pfx"
if (-not (Test-Path $clientCertPath)) {
    # Create a self-signed client certificate for testing
    # Note: In production, client certificates should be issued by a trusted CA
    $clientCert = New-KrSelfSignedCertificate -DnsNames 'test-client' -Exportable

    # Export for testing
    Export-KrCertificate -Certificate $clientCert -FilePath $clientCertPath `
        -Format pfx -IncludePrivateKey -Password (ConvertTo-SecureString -String 'test' -AsPlainText -Force)

    Write-Host 'Created test certificates:'
    Write-Host "  Server: $serverCertPath"
    Write-Host "  Client: $clientCertPath"
    Write-Host '  Password: test'
    Write-Host ''
    Write-Host 'Note: Using self-signed certificates for testing.'
    Write-Host '      In production, use certificates from a trusted CA.'
    Write-Host ''
}

# Import the client certificate so we can trust it for authentication.
# (The client will present this cert during TLS; the authentication layer still validates an X509Chain.)
$clientCertForTrust = Import-KrCertificate -FilePath $clientCertPath -Password (ConvertTo-SecureString -String 'test' -AsPlainText -Force)

# 5. Configure HTTPS to require client certificates
# NOTE: This callback is invoked on Kestrel threadpool threads during TLS handshake.
# It must be pure .NET (not a PowerShell scriptblock), otherwise you'll get
# "There is no Runspace available" errors.
#
# This example demonstrates compiling a C# snippet with Roslyn into the TLS callback delegate.
$clientCertValidationCode = @'
// certificate: X509Certificate2
// chain: X509Chain
// sslPolicyErrors: SslPolicyErrors

// AllowCertificate mode: permit connections without a client certificate.
if (certificate is null)
{
    return true;
}

// Accept valid chains.
if (sslPolicyErrors == SslPolicyErrors.None)
{
    return true;
}

// Dev-friendly: allow self-signed / untrusted chains.
return sslPolicyErrors == SslPolicyErrors.RemoteCertificateChainErrors;
'@

Set-KrServerHttpsOptions -ClientCertificateMode AllowCertificate `
    -ClientCertificateValidationLanguage CSharp `
    -ClientCertificateValidationCode $clientCertValidationCode

# Note: You can also use a built-in .NET callback instead of compiling code:
# Set-KrServerHttpsOptions -ClientCertificateMode AllowCertificate -ClientCertificateValidation ([Kestrun.Certificates.ClientCertificateValidationCallbacks]::AllowMissingOrSelfSignedForDevelopment)
#
# Tip: If you prefer storing the snippet in a file, use -ClientCertificateValidationCodePath with a .cs/.csx or .vb file.

# 6. Add HTTPS endpoint
Add-KrEndpoint -Port $Port -IPAddress $IPAddress -X509Certificate $serverCert

# 7. Configure client certificate authentication
# Note: For self-signed client certificates, we need to allow all certificate types
# and disable some validation that requires a proper certificate chain
$authOptions = [Kestrun.Authentication.ClientCertificateAuthenticationOptions]::new()
$authOptions.AllowedCertificateTypes = [Microsoft.AspNetCore.Authentication.Certificate.CertificateTypes]::All
$authOptions.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck

# Trust the self-signed client certificate as a root for chain building.
$authOptions.CustomTrustStore = [System.Security.Cryptography.X509Certificates.X509Certificate2Collection]::new()
$null = $authOptions.CustomTrustStore.Add($clientCertForTrust)

Add-KrClientCertificateAuthentication -AuthenticationScheme 'Certificate' -Options $authOptions

# 8. Finalize configuration (build internal pipeline)
Enable-KrConfiguration

# 9. Map secured route group using certificate authentication
Add-KrMapRoute -Verbs Get -Pattern '/secure/cert/hello' -AuthorizationScheme 'Certificate' -ScriptBlock {
    $cert = $Context.Connection.ClientCertificate
    if ($null -eq $cert) {
        Write-KrJsonResponse @{
            error = 'No client certificate provided'
        } -StatusCode 401
        return
    }

    Write-KrJsonResponse @{
        message = 'Hello from client certificate authentication'
        subject = $cert.Subject
        issuer = $cert.Issuer
        thumbprint = $cert.Thumbprint
        validFrom = $cert.NotBefore.ToString('o')
        validTo = $cert.NotAfter.ToString('o')
    }
}

Add-KrMapRoute -Verbs Get -Pattern '/secure/cert/info' -AuthorizationScheme 'Certificate' -ScriptBlock {
    $cert = $Context.Connection.ClientCertificate
    if ($null -eq $cert) {
        Write-KrJsonResponse @{
            error = 'No client certificate provided'
        } -StatusCode 401
        return
    }
    $claims = $Context.User.Claims | ForEach-Object {
        @{
            type = $_.Type
            value = $_.Value
        }
    }
    Write-KrJsonResponse @{
        authenticationType = $Context.User.Identity.AuthenticationType
        isAuthenticated = $Context.User.Identity.IsAuthenticated
        name = $Context.User.Identity.Name
        claims = $claims
        certificate = @{
            subject = $cert.Subject
            issuer = $cert.Issuer
            thumbprint = $cert.Thumbprint
            serialNumber = $cert.SerialNumber
        }
    }
}

# 10. Add a public info endpoint
Add-KrMapRoute -Verbs Get -Pattern '/info' -ScriptBlock {
    Write-KrJsonResponse @{
        message = 'Client Certificate Authentication Demo'
        endpoints = @(
            @{
                path = '/secure/cert/hello'
                method = 'GET'
                description = 'Returns certificate information'
                requiresAuth = $true
            }
            @{
                path = '/secure/cert/info'
                method = 'GET'
                description = 'Returns detailed authentication and claims information'
                requiresAuth = $true
            }
        )
        testInstructions = @{
            clientCertPath = 'client-cert.pfx (in same directory as script)'
            password = 'test'
            example = "Invoke-RestMethod -Uri 'https://localhost:$Port/secure/cert/hello' -Certificate `$cert -SkipCertificateCheck"
        }
    }
}

# 11. Start server (Ctrl+C to stop)
Write-Host "`nServer starting on https://localhost:$Port"
Write-Host 'Test the secured endpoint with:'
Write-Host "  `$cert = Import-KrCertificate -FilePath '$clientCertPath' -Password (ConvertTo-SecureString 'test' -AsPlainText -Force)"
Write-Host "  Invoke-RestMethod -Uri 'https://localhost:$Port/secure/cert/hello' -Certificate `$cert -SkipCertificateCheck"
Write-Host "`nPress Ctrl+C to stop"

Start-KrServer -CloseLogsOnExit

Step-by-step

  1. Logging: Register a console logger at Debug.
  2. Server: Create a server instance with New-KrServer.
  3. Server cert: Create/load an HTTPS server certificate (self-signed for local testing).
  4. Client cert: Create/load a client certificate (self-signed for local testing).
  5. TLS validation (Kestrel): Configure HTTPS to accept dev certificates using Set-KrServerHttpsOptions.

    • Use -ClientCertificateMode AllowCertificate so /info (and probes) can work without a client certificate.
    • Provide a TLS callback using either:
      • -ClientCertificateValidation (built-in .NET delegate), or
      • -ClientCertificateValidationCode / -ClientCertificateValidationCodePath (compile C#/VB.NET with Roslyn).
  6. Listener: Bind HTTPS using Add-KrEndpoint.
  7. Authentication: Register certificate auth with Add-KrClientCertificateAuthentication.
  8. Finalize: Apply all staged configuration using Enable-KrConfiguration.
  9. Routes: Map /secure/cert/* routes that require the Certificate scheme via Add-KrMapRoute.
  10. Start: Run the server with Start-KrServer.

TLS vs authentication (important)

Client certificate authentication runs at the ASP.NET Core authentication layer. Kestrel must first accept the client certificate during the TLS handshake, otherwise the request never reaches Kestrun.

Try it

Assumptions:

  • Server is listening on https://127.0.0.1:5000.
  • Certificates are self-signed for local development.

Run the sample from the repository root:

pwsh .\docs\_includes\examples\pwsh\8.8-Client-Certificate-Authentication.ps1

Public route:

curl -k https://127.0.0.1:5000/info

Without a client certificate, the protected route should fail:

try {
    Invoke-RestMethod https://127.0.0.1:5000/secure/cert/hello -SkipCertificateCheck -ErrorAction Stop
} catch {
    $_.Exception.Message
}

With the generated client certificate:

$clientCertPath = '.\docs\_includes\examples\pwsh\client-cert.pfx'
$password = ConvertTo-SecureString -String 'test' -AsPlainText -Force
$cert = Import-KrCertificate -FilePath $clientCertPath -Password $password

Invoke-RestMethod https://127.0.0.1:5000/secure/cert/hello -Certificate $cert -SkipCertificateCheck
Invoke-RestMethod https://127.0.0.1:5000/secure/cert/info  -Certificate $cert -SkipCertificateCheck

Troubleshooting

Symptom Cause / Details Fix / Guidance
Client says: “The decryption operation failed” TLS handshake rejected the client certificate (common with self-signed certs) Configure Kestrel TLS callback via Set-KrServerHttpsOptions
Server logs mention RemoteCertificate… Certificate rejected by TLS validation callback Use AllowCertificate + dev-friendly validation (-ClientCertificateValidation*)
“There is no Runspace available” TLS callback is a PowerShell script block running on Kestrel TLS threads Use a pure .NET delegate or compile code via -ClientCertificateValidationCode*
/info fails unless client cert provided TLS mode is RequireCertificate Prefer -ClientCertificateMode AllowCertificate and enforce auth per-route
Protected routes return 401 with a cert Cert auth not registered or scheme mismatch Ensure Add-KrClientCertificateAuthentication and route uses -AuthorizationScheme 'Certificate'

References


Previous / Next

Previous: Claims & Policies Next: OpenID Connect (Okta)