Client certificates (mTLS) new in 4.2.0
Not in the Microsoft Store build. Store certification rejected certificate generation under policies 10.2.10 and 10.2.10.1, so the Store package has no mTLS, no Client Certificate card, and no certificate-minting code. Install from a release if you need it — see docs/STORE-MSIX.md.
Optional, off by default. Turn on Require mTLS for all routes and funnels on the Settings tab
and the proxy switches from http://127.0.0.1:5559 to https://127.0.0.1:5559 and demands a
client certificate on every connection — routes, funnels, everything.
This is a second factor for the same door, not a replacement for the proxy key. A key sits in whatever config file the client reads it from, so any process that can read that file can spend it; a certificate has to be installed as well, and both are checked. Every request still needs the key of the endpoint it is calling.
Changing the setting requires a full restart of RavensPort. The listener's scheme and its certificate demand are fixed when Kestrel binds, so nothing about this takes effect until the app is restarted — the Settings tab says so in red until it is.
Generate the certificate first. The switch refuses to turn on until one exists. RavensPort never mints a certificate behind the switch, because generating one means choosing the PFX password and a password the app picked for you is a password every install shares. Use Generate new certificate below, choose your own, then come back and enable mTLS.
Generating and exporting#
Generate new certificate asks for a password, then mints a self-signed certificate. RavensPort keeps it in the vault with everything else and presents it at both ends: the listener serves it and demands it back, and the funnel presents it when it dials this app's own routes.
- You choose the password, and it is shown nowhere afterwards — not on the status line, not in the log. Write it down before confirming. There is no way to recover it; the way out of a forgotten one is generating another certificate and reinstalling it everywhere.
- Export certificate asks where to save the
.pfx. That file is the credential — whoever holds it can call the proxy — so put it where the client that needs it can read it, and nowhere else. The password stops Windows and curl refusing a password-less PFX; it does not make a copy of the file safe to leave lying around. - Generating a new certificate invalidates the old one immediately. Every client holding the previous file is refused. Export the new one, install it everywhere it is used, and restart.
Pointing a client at it#
The certificate is self-signed, so nothing trusts it until you say so. Install it as a trust anchor on the machine that will be calling the proxy, and every client there verifies the listener the ordinary way from then on.
Two things have to be true before a call succeeds, and they are easy to conflate because one file carries both. Your client must trust the certificate RavensPort serves (steps 1–2), and it must present that certificate back (step 3). Getting only the first right produces a handshake that fails after the trust check passes, which is a confusing place to land.
Please execute the following powershell commands from the location where cert file (RavensPort_ClientCert.pfx) was downloaded.
1. Get the public half out of the export. The .pfx carries the private key; a trust store only
needs the certificate, and only the certificate should travel.
$cert = Get-PfxCertificate -FilePath RavensPort_ClientCert.pfx # prompts for the password
Export-Certificate -Cert $cert -FilePath ravensport.cer
certutil -encode ravensport.cer ravensport.pem # PEM, for clients that want one
2. Install it into the Windows trust store.
# This user only — no administrator rights needed
Import-Certificate -FilePath ravensport.cer -CertStoreLocation Cert:\CurrentUser\Root
# Machine-wide, for services and other accounts — run as Administrator
Import-Certificate -FilePath ravensport.cer -CertStoreLocation Cert:\LocalMachine\Root
Windows asks you to confirm, and it is worth reading: a root you install is one that machine will trust wherever it is presented. Take it back out when the certificate is rotated or retired, or the machine goes on trusting a certificate you no longer control.
Get-ChildItem Cert:\CurrentUser\Root |
Where-Object { $_.Subject -eq 'CN=RavensPort MCP Client' } |
Remove-Item
3. Install the client certificate where the caller can find it. Step 2 taught the machine to
trust RavensPort; this is the other half of the handshake — the certificate your client presents
back. Import the .pfx into your personal store:
$client = Import-PfxCertificate `
-FilePath RavensPort_ClientCert.pfx `
-CertStoreLocation Cert:\CurrentUser\My `
-Password (Read-Host 'PFX password' -AsSecureString)
$client.Thumbprint
Keep that thumbprint. It is how you name this certificate later, and the name matters: every
certificate RavensPort mints carries the same CN=RavensPort MCP Client, and these certificates can be indistinguishable by subject alone.
4. Call the proxy. In PowerShell, curl is an alias for Invoke-WebRequest and \ is not a
line continuation — write curl.exe and use a backtick, or the command silently becomes something
else entirely.
curl.exe --cert "CurrentUser\MY\<thumbprint from step 3>" `
-H "X-Proxy-Key: <this-route's-key>" `
https://127.0.0.1:5559/app/my-service/foo
The certificate is named by store reference, not by file, and on Windows that is the only form
that works. The curl.exe in System32 is built against Schannel, and Schannel cannot load a
client certificate from a .pfx on disk: --cert cert.pfx:<password> --cert-type P12 fails the
handshake with schannel: AcquireCredentialsHandle failed: SEC_E_UNKNOWN_CREDENTIALS, which reads
like a bad password and is not one. That P12 form belongs to an OpenSSL-backed curl — WSL, most
Linux distributions — where the file and password are passed directly and no import is needed:
curl --cert cert.pfx:<your-password> --cert-type P12 \
--cacert ravensport.pem \
-H "X-Proxy-Key: <this-route's-key>" \
https://127.0.0.1:5559/app/my-service/foo
Run curl --version if you are unsure which you have; the backend is named on the first line.
PowerShell can also make the call natively, taking the .pfx directly and needing no import:
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 `
((Resolve-Path RavensPort_ClientCert.pfx).Path, '<your-password>', 'PersistKeySet')
Invoke-WebRequest -Uri https://127.0.0.1:5559/app/my-service/foo `
-Certificate $cert `
-Headers @{ 'X-Proxy-Key' = '<this-route''s-key>' } `
-UseBasicParsing
PersistKeySet is load-bearing: without it the private key can be discarded after loading, and the
handshake then fails on a certificate that appeared to read back perfectly.
Neither form disables verification — no --insecure on the curl line, no -SkipCertificateCheck
on the PowerShell one — and neither should. Step 2 is what makes ordinary verification succeed;
reaching for those flags instead is the failure described below.
Node needs both halves handed to it explicitly, since it carries its own OpenSSL and reads nothing from the Windows stores — steps 2 and 3 do nothing for it:
const https = require('node:https');
const fs = require('node:fs');
const path = require('node:path');
// Resolved against this file, not the working directory: a bare 'cert.pfx' is looked up
// wherever node happened to be started from, which is rarely where the file is.
const here = (name) => path.join(__dirname, name);
https.request({
host: '127.0.0.1',
port: 5559,
path: '/app/my-service/foo',
headers: { 'X-Proxy-Key': '<this-route\'s-key>' },
pfx: fs.readFileSync(here('RavensPort_ClientCert.pfx')),
passphrase: '<your-password>',
ca: fs.readFileSync(here('ravensport.pem')),
rejectUnauthorized: true
}, (res) => {
// socket.authorized is the answer to "did verification actually happen", which the status
// code does not tell you.
console.log(res.statusCode, res.socket.authorized);
}).end();
ca: replaces Node's trust store rather than adding to it, so this agent verifies RavensPort
and nothing else. That is the right trade for a client that only ever calls the proxy; a process
that also talks to ordinary HTTPS hosts wants NODE_EXTRA_CA_CERTS=ravensport.pem instead, which
appends.
What each failure looks like#
Four ways to run that snippet, and what each one returns. The point of the table is the middle two: they are what proves the first row means anything.
| Variant | Result |
|---|---|
| As written above | HTTP 200, socket.authorized === true |
ca: removed |
DEPTH_ZERO_SELF_SIGNED_CERT — self-signed certificate |
pfx:/passphrase: removed |
ERR_SSL_SSLV3_ALERT_CERTIFICATE_UNKNOWN, SSL alert 46 |
Wrong passphrase: |
Error: mac verify failure, thrown synchronously |
DEPTH_ZERO_SELF_SIGNED_CERTmeans the trust anchor was doing real work. RavensPort's certificate is its own root and Node trusts no root it was not given, so droppingca:fails rather than quietly succeeding — which is what tells yourejectUnauthorized: trueis not decorative here.- SSL alert 46 is
certificate_unknown, sent by RavensPort, and it is the reply to a client that presented no certificate. It arrives as a TLS alert rather than an HTTP status because the handshake never completed — the same reason an expired certificate shows up as a dropped connection with nothing to read. Seeing it means mTLS is genuinely enforced on the listener, not merely offered. mac verify failureis thrown byhttps.requestitself, before a socket is opened, when the PFX cannot be decrypted. It is athrow, not an'error'event, so areq.on('error', …)handler never sees it and an otherwise careful script dies with an unhandled exception. Wrap the call intry/catchif a wrong password is a case you want to report.
Testing these variants: pass
agent: false. Node's global agent reuses keep-alive sockets and its cache key does not coverpassphrase, so a failing variant run after a successful one can ride the already-authenticated connection and returnHTTP 200. A test that cannot fail is worse than no test.
rejectUnauthorized: true is the most important line in that snippet. What it prevents is worth
being precise about.
Loopback is not a trust boundary. 127.0.0.1:5559 is first come, first served: any process running
as any user on the machine can bind it whenever RavensPort is not already holding it — before the
app starts at boot, after it exits, in the window a crash leaves open. Binding a loopback port needs
no privilege and no consent, and nothing about the address makes whatever answers on it the proxy.
Then look at the order things happen in. The TLS handshake completes first. The request — method,
path, and every header, X-Proxy-Key and anything else your client attaches included — goes out
after it. Verifying the server is the only step that happens before your client has said
anything. A client that skips it has already handed the endpoint's proxy key to whatever answered by
the time there is any way to notice, and that key does not come back: it spends the OAuth grant
behind that route, and it works against the real proxy afterwards.
The client certificate does not cover this. mTLS is mutual, and the two halves protect different parties — the certificate your client presents proves the client to RavensPort, and the certificate RavensPort presents is what proves the server to your client. An impostor on 5559 can demand a client certificate exactly as RavensPort does; asking costs it nothing, and your client will present one. The trust anchor from step 2 is the only thing that tells the two listeners apart.
RavensPort itself refuses to start when the port is already taken and says so on the setup page, so a squatter is visible from the app's side. Your client has no such view. It connects, something answers, and it either checks the certificate or it does not — and that check is the whole of what stands between a local process and your credentials.
Three things decide whether that works:
- Use
127.0.0.1orlocalhost, and nothing else. The certificate carries exactly those names —localhost,127.0.0.1,::1— and checking the hostname is part of what verification does. Any other name for the same machine is refused: Node saysERR_TLS_CERT_ALTNAME_INVALID. - curl on Windows reads the Windows trust store. Both the
curl.exeinSystem32and the one Git Bash ships are built against Schannel, so for this half step 2 is all they need and no--cacertbelongs on the command line. A curl built against OpenSSL — WSL, most Linux distributions — reads its own CA bundle instead: pass--cacert ravensport.pemthere rather than installing a root. Being Schannel is also why those two need step 3 and the store reference; see step 4. - Node does not read the Windows stores at all, whatever steps 2 and 3 did, because it carries
its own OpenSSL. Both halves have to be handed to it in the options object the way the snippet
does —
ca:for the trust anchor,pfx:for the certificate it presents.NODE_EXTRA_CA_CERTScovers the first for a whole process; there is no environment variable for the second.
On RavensPort's side of the same handshake: it compares the thumbprint of what it was handed against its own and refuses anything else, it enforces the certificate's expiry date itself, and every request still needs the proxy key of the endpoint it is calling.
Expiry#
Certificates are minted with a 90-day life. Nothing renews them, and there is no CA behind them — no revocation list to publish, no way to recall a copy that leaked — so the expiry date is the only thing that retires one.
It is enforced, at both ends. Past the date the proxy refuses the certificate it issued, including
its own funnel's hop into its own routes. An expired certificate fails during the TLS handshake,
so clients see a dropped connection rather than a status code — there is no 403 to read, which
is why the date is worth watching.
The Settings tab shows when the current certificate expires, and says so in red once it is within 14 days. Rotating means generating, exporting, installing on every client, and restarting, so it is not something to start on the day it stops working.
Edit this page on the wiki Request support · Report vulnerabilities privately