The Npgsql “bad protocol version” error on macOS is caused by a TLS 1.3 packet fragmentation bug in Apple’s SecureTransport library – not an invalid certificate. The fastest fix is to cap your PostgreSQL server to TLS 1.2 by setting ssl_max_protocol_version = ‘TLSv1.2’ in postgresql.conf or your CloudNativePG YAML. Your connection stays fully encrypted.
The Error: Works on Linux, Breaks on Mac
You have a .NET 6 application connecting to PostgreSQL over SSL. Everything works in your Kubernetes pods running Linux. But the moment you try the same connection from your Mac, you get this:
Npgsql.NpgsqlException (0x80004005): Exception while performing SSL handshake ---> Interop+AppleCrypto+SslException: bad protocol version
- Your TLS version settings on the client side
- Whether the Let’s Encrypt certificate is valid or expired
- Your Npgsql connection string SSL parameters
- Firewall rules or network policies blocking the connection
Root Cause: Three Things Colliding at Once
This error is not caused by a single bug – it’s a collision of three independent factors that only line up on macOS with .NET 6 and an RSA certificate. Understanding each one makes the fix obvious.
1. PostgreSQL Uses STARTTLS, Not Straight TLS
HTTPS encrypts data from the very first byte of a connection. PostgreSQL works differently:
- The client opens a plain-text TCP connection to the server
- The client sends a special SSLRequest packet asking to upgrade to SSL
- The server responds with ‘S’ to accept, then the TLS handshake begins mid-stream
- Only after the handshake completes does encrypted traffic begin
This STARTTLS flow is fundamentally different from what most TLS libraries are designed and tested against. It introduces a subtle timing and sequencing complexity that most libraries handle – but not all of them do it perfectly under all conditions.
2. Let’s Encrypt RSA Chains Are Large
A Let’s Encrypt RSA 2048 certificate chain contains several components sent together in TLS 1.3:
- End-entity certificate: The cert issued for your specific domain
- Intermediate certificate(s): One or two Let’s Encrypt intermediates (R3, E1, etc.)
- Encrypted Extensions: Additional TLS 1.3 metadata bundled into the same record
- Certificate Verify + Finished: Signature and completion messages
Because this combined payload can be several kilobytes in size, it often exceeds the TCP Maximum Segment Size (typically 1,460 bytes on standard Ethernet). When that happens, the TCP layer splits the data across multiple packets – this is called TCP fragmentation. On a healthy TLS stack, the receiver buffers all fragments and reconstructs the full record before processing. On macOS with .NET 6, that’s exactly where things go wrong.
3. AppleCrypto Misreads the Fragmented Packet
Here’s the key difference between platforms:
- Linux (.NET + OpenSSL): OpenSSL correctly buffers incoming TCP data, waits for a complete TLS record, then processes it. Fragmentation is fully transparent.
- macOS (.NET 6 + AppleCrypto): Apple’s SecureTransport, when operating in a STARTTLS context, treats the second TCP packet – a continuation of the same TLS record – as a brand new standalone TLS record. That continuation fragment doesn’t start with a valid TLS header, so SecureTransport panics and reports: bad protocol version.
Why the error message says ‘bad protocol version’
A TLS record header is structured like this:
- Byte 1: Content type (e.g., 0x16 = Handshake)
- Bytes 2–3: Protocol version (e.g., 0x0303 = TLS 1.2)
- Bytes 4–5: Record length
When SecureTransport reads the middle of a fragmented certificate message as a new record, bytes 2–3 contain certificate data – not a valid version field. The library reads an unrecognised version number and surfaces it as ‘bad protocol version’.
Linux vs macOS: At a Glance
Here’s how the same connection behaves across different environments and configurations:
| Scenario | Linux + OpenSSL | macOS + AppleCrypto (.NET 6) |
| TLS 1.3 + STARTTLS + RSA cert | Works perfectly | bad protocol version |
| TLS 1.2 + STARTTLS + RSA cert | Works perfectly | Works perfectly |
| TLS 1.3 + ECDSA cert (small) | Works perfectly | Usually works (smaller packet) |
| TLS 1.3 + .NET 7 or later | Works perfectly | Fixed in later runtimes |
| Fragmented TLS record handling | Correct reassembly | Misreads 2nd packet as new record |
Before You Apply a Fix: Quick Checks
Rule out other common causes before diving into the fixes below. Confirm all of the following are true in your setup:
- The error only appears on macOS: If you see it on Linux too, the root cause is different – check your certificate chain and server TLS config directly.
- You are running .NET 6: This specific bug was resolved in .NET 7. If you’re already on .NET 7+, something else is causing the error.
- Your certificate is a Let’s Encrypt RSA cert: ECDSA certificates are smaller and less likely to trigger the fragmentation. Confirm your cert type with: openssl x509 -in cert.pem -text | grep ‘Public Key Algorithm’
- SSL is enabled on the PostgreSQL server: Run SHOW ssl; on the server. It should return ‘on’.
- The Npgsql connection string doesn’t already disable SSL: Check for SSL Mode=Disable in your connection string – if present, SSL is off entirely and this error shouldn’t occur.
Quick sanity check
If the exact same connection string works on Linux pods but fails on your Mac only, you have confirmed this bug. You can proceed directly to Fix 1 below.
How to Fix It: Four Options
There are four ways to resolve this error, ranging from a 2-line server config change to a full runtime upgrade. Start with Fix 1 – it’s the most reliable and requires the least effort.
| Fix Option | Where Applied | Works for .NET 6? | Effort |
| Cap server to TLS 1.2 | Server / CNPG YAML | Yes | Low |
| Upgrade to .NET 7+ | Client / codebase | N/A (fix) | Medium |
| Switch to ECDSA certificate | Server / Let’s Encrypt | Usually | Medium |
| Disable SSL (dev only) | Client / conn string | Yes | Low – never prod |
Fix 1 – Cap PostgreSQL to TLS 1.2 (Recommended)
This is the cleanest, most reliable fix. By preventing TLS 1.3 from being negotiated, you bypass the fragmentation bug entirely. TLS 1.2 uses a different handshake structure that AppleCrypto handles correctly – even when the certificate chain is large.
Why this works:
- Different packet structure: TLS 1.2 sends the certificate in a separate record from other handshake messages, reducing payload size and fragmentation likelihood.
- No STARTTLS fragmentation bug: AppleCrypto’s fragmentation bug is specific to TLS 1.3’s combined record format. TLS 1.2 doesn’t use it.
- Zero application changes: This is a server-side configuration – your .NET code, connection strings, and Npgsql version stay exactly the same.
- Fully encrypted: TLS 1.2 with strong cipher suites is widely accepted as secure. You’re not sacrificing security for compatibility.
Option A – CloudNativePG (CNPG) cluster YAML:
postgresql:
parameters:
ssl_min_protocol_version: "TLSv1.2"
ssl_max_protocol_version: "TLSv1.2"
Option B – postgresql.conf (any standard PostgreSQL 12+ install):
ssl_min_protocol_version = 'TLSv1.2' ssl_max_protocol_version = 'TLSv1.2'
After editing postgresql.conf, reload without a full restart:
Step 5: Set Up Auto-Renewal
acme.sh --install-cronjob
Is TLS 1.2 still secure in 2025?
Yes – with proper cipher configuration. Here’s what to know:
- TLS 1.2 is still the most widely deployed TLS version in production database infrastructure worldwide
- PostgreSQL 12+ enables only strong cipher suites by default – no insecure ciphers are active
- You can explicitly harden further by adding: ssl_ciphers = ‘HIGH:!aNULL:!MD5’
Revisit this cap once you’ve upgraded to .NET 7 or later, which restores full TLS 1.3 support on macOS.
Fix 2 – Upgrade Your .NET Runtime to 7 or Later
Microsoft resolved the AppleCrypto STARTTLS fragmentation issue in .NET 7. Upgrading is the definitive, permanent fix – it restores full TLS 1.3 support on macOS and requires no server-side changes.
Trade-offs to consider:
- Pros: Permanent fix, restores TLS 1.3, no server config changes needed, future-proof
- Cons: Requires testing your application on the new runtime, potential breaking changes between .NET 6 and .NET 8
- Recommended target: .NET 8 (LTS) rather than .NET 7, which is already end-of-life
Update your project file:
<!-- Before --> <TargetFramework>net6.0</TargetFramework> <!-- After --> <TargetFramework>net8.0</TargetFramework>
Fix 3 – Switch to an ECDSA Certificate
An ECDSA P-256 certificate chain is significantly smaller than an RSA 2048 chain – often fitting inside a single TCP packet. No fragmentation means no bug.
When this fix is appropriate:
- You control certificate issuance: Your CA supports ECDSA (Let’s Encrypt does – use the –key-type ecdsa flag with certbot)
- You want to avoid server config changes: No postgresql.conf edits required
- You’re using internal or private CAs: Easy to reissue with ECDSA key type
When this fix is not enough on its own:
- Network MTU variability: On some networks, even ECDSA chains can be fragmented – the TLS 1.2 cap is more reliable
- Not guaranteed: Treat ECDSA as a complementary improvement, not a primary fix on its own
Fix 4 – Disable SSL on Local Dev Only
If you only need to unblock local development on your Mac and you’re connecting to a local or dev database, you can temporarily disable SSL:
Host=localhost;Database=mydb;Username=user;Password=pass;SSL Mode=Disable
- Local-only dev database: Running PostgreSQL on your Mac (localhost), no sensitive data
- Temporary unblocking: You’re applying Fix 1 or Fix 2 and need to keep working in the meantime
Never use SSL Mode=Disable in production
Disabling SSL removes all encryption from your database connection. Never use this in:
- Production environments
- Staging or QA environments
- Any shared or remote database
- Any database containing real user data
Apply Fix 1 or Fix 2 for any environment that matters.
Step-by-Step: Applying Fix 1 in Under 5 Minutes
If you are running PostgreSQL via CloudNativePG in Kubernetes, here is the complete process:
- Open your CNPG Cluster manifest: Locate the YAML file defining your CloudNativePG Cluster resource.
- Find or create the parameters block: Look for spec.postgresql.parameters – create it if it doesn’t exist.
- Add the two TLS version settings: ssl_min_protocol_version: “TLSv1.2” and ssl_max_protocol_version: “TLSv1.2”
- Apply the manifest: Run kubectl apply -f your-cluster.yaml
- Wait for reconciliation: CloudNativePG will reload the config – typically 10–30 seconds, no pod restart needed.
- Retry your Mac connection: The error should be gone. Run your .NET app or psql from your Mac to verify.
Run this from your terminal to confirm the negotiated protocol:
openssl s_client -starttls postgres -connect yourhost:5432
Look for these two lines in the output:
- Protocol: TLSv1.2
- Verify return code: 0 (ok)
Final Words
The Npgsql “bad protocol version” error on macOS is one of those bugs that looks simple on the surface but has a genuinely layered root cause. The error message points you toward a TLS version problem, but the actual culprit is a fragmentation bug in Apple’s TLS library – one that only surfaces when three specific conditions collide simultaneously.
Here’s the full picture in three bullets:
- The trigger: PostgreSQL’s STARTTLS upgrade + a large Let’s Encrypt RSA chain + TLS 1.3 = a fragmented TCP response that AppleCrypto misreads.
- The fastest fix: Cap PostgreSQL to TLS 1.2 with two lines of config. No code changes. No restarts. Under 5 minutes.
- The permanent fix: Upgrade to .NET 7 or later. Microsoft patched the AppleCrypto STARTTLS handling, restoring full TLS 1.3 support on macOS.
Whichever fix you choose, your connection remains encrypted and your Linux infrastructure stays completely unaffected. The bug is isolated entirely to the macOS + .NET 6 client path.
Frequently Asked Questions
Why does this error only appear on macOS and not on Linux?
On Linux, .NET uses OpenSSL, which correctly buffers and reassembles fragmented TLS records before processing them. On macOS, .NET 6 uses Apple’s own SecureTransport library, which has a specific bug in how it handles fragmented TLS 1.3 records during a STARTTLS upgrade. The two key differences:
- TLS backend: Linux uses OpenSSL; macOS uses AppleCrypto / SecureTransport
- Fragmentation handling: OpenSSL reassembles fragments correctly; SecureTransport misreads them in STARTTLS mode
Is this a bug in Npgsql?
No. Npgsql is behaving correctly. The bug is in Apple’s SecureTransport library, which .NET 6 calls on macOS for TLS operations. Three clues that confirm this:
- The stack trace shows Interop+AppleCrypto – that’s Apple’s library, not Npgsql
- The fix landed in the .NET runtime (version 7), not in a Npgsql release
- The same Npgsql version works perfectly on Linux with the same server
Will capping TLS to 1.2 affect my Linux pods or other clients?
No. All modern PostgreSQL clients support TLS 1.2, including:
- JDBC (Java)
- psycopg2 and asyncpg (Python)
- PgBouncer and pgpool-II
- Every version of Npgsql
- The psql command-line client
Your Linux pods will connect without any changes – they simply negotiate TLS 1.2 instead of 1.3, which is entirely transparent to the application layer.
Does this affect PostgreSQL 16 and 17?
Yes. The problem is on the client side (macOS + .NET 6 + AppleCrypto), not in any specific PostgreSQL version. Any PostgreSQL version that supports TLS 1.3 will trigger the bug when connected from macOS with .NET 6 using a large RSA certificate chain. This includes versions 12 through 17.
Can I fix this without touching the server at all?
Yes – you have two server-free options:
- Upgrade to .NET 7+: The definitive fix. Microsoft patched the AppleCrypto STARTTLS handling. Upgrade to .NET 8 (LTS recommended).
- Switch to ECDSA certificate: Smaller chain, less likely to fragment. Works in most network configurations, though not guaranteed on all.
That said, the server-side TLS 1.2 cap (Fix 1) is still the fastest, most reliable option – 2 lines of config, under 5 minutes.
Is it safe to set both ssl_min_protocol_version and ssl_max_protocol_version to TLSv1.2?
For internal database connections – especially inside a Kubernetes cluster with network policies – yes. Here’s a quick checklist:
- Safe: Internal Kubernetes cluster connections with network policies
- Safe: Database access restricted to known application clients
- Safe: When combined with ssl_ciphers = ‘HIGH:!aNULL:!MD5’
- Review: Public-facing database endpoints – consider upgrading runtime instead
Once you’ve upgraded your .NET runtime to version 7 or later, you can remove the ssl_max_protocol_version cap to re-enable TLS 1.3.
Priya Mervana
Verified Web Security Experts
Priya Mervana is working at SSLInsights.com as a web security expert with over 10 years of experience writing about encryption, SSL certificates, and online privacy. She aims to make complex security topics easily understandable for everyday internet users.



