<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Error Rescue]]></title><description><![CDATA[Error Rescue]]></description><link>https://errorrescue.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 03 Nov 2026 20:29:32 GMT</lastBuildDate><atom:link href="https://errorrescue.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Debugging SSL Errors From the Server Side: ERR_SSL_PROTOCOL_ERROR, Untrusted Certificates and Cloudflare SSL Modes]]></title><description><![CDATA[How to diagnose broken handshakes, missing intermediates and Cloudflare redirect loops with openssl, Nginx and Apache]]></description><link>https://errorrescue.hashnode.dev/debugging-ssl-errors-from-the-server-side-err-ssl-protocol-error-untrusted-certificates-and-cloudflare-ssl-modes</link><guid isPermaLink="true">https://errorrescue.hashnode.dev/debugging-ssl-errors-from-the-server-side-err-ssl-protocol-error-untrusted-certificates-and-cloudflare-ssl-modes</guid><category><![CDATA[SSL]]></category><category><![CDATA[Devops]]></category><category><![CDATA[nginx]]></category><category><![CDATA[cloudflare]]></category><dc:creator><![CDATA[Alex]]></dc:creator><pubDate>Sat, 24 Oct 2026 15:56:08 GMT</pubDate><content:encoded><![CDATA[<p>When a visitor reports "Your connection is not private" or <code>ERR_SSL_PROTOCOL_ERROR</code>, most of the advice online is aimed at them: clear the cache, check the clock, turn off the antivirus. That's sometimes the answer. But if you run the server and more than one person sees the error, the problem is usually yours, and the browser is just the messenger.</p>
<p>I've spent over eight years fixing WordPress sites, VPS boxes and Cloudflare setups, and nearly every SSL ticket I've handled came down to one of four things: the server isn't speaking TLS on the port it should, the certificate chain is incomplete, the protocol or cipher settings are wrong, or Cloudflare and the origin disagree about who is doing the encryption. This post goes through how to tell which one you have, working from a terminal instead of a browser.</p>
<h2>Start with openssl s_client, not the browser</h2>
<p>Browsers cache, retry, fall back and hide the details. <code>openssl s_client</code> shows you exactly what the server sends:</p>
<pre><code class="language-bash">openssl s_client -connect example.com:443 -servername example.com &lt;/dev/null
</code></pre>
<p><code>-servername</code> matters. Without SNI, a server hosting several sites may hand you the default certificate, and you'll end up chasing a problem that doesn't exist.</p>
<p>Three parts of the output are worth reading.</p>
<p><strong>The certificate chain</strong> (<code>Certificate chain</code>). You should see your leaf certificate at depth 0 and at least one intermediate at depth 1. If only depth 0 is listed, the server isn't sending the intermediate.</p>
<p><strong>The verify result</strong> near the bottom:</p>
<pre><code>Verify return code: 0 (ok)
</code></pre>
<p>Anything other than <code>0</code> is a lead to follow.</p>
<p><strong>The negotiated protocol and cipher:</strong></p>
<pre><code>New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
</code></pre>
<p>If the handshake fails completely and you get something like <code>wrong version number</code> or <code>unexpected eof</code>, the problem comes before certificates. More on that below.</p>
<p>To verify against the same trust store that most clients use, and to check the hostname as well:</p>
<pre><code class="language-bash">openssl s_client -connect example.com:443 -servername example.com \
  -verify_return_error -verify_hostname example.com &lt;/dev/null
</code></pre>
<h2>Reading the verify codes</h2>
<p>These are the codes I run into most, and what they usually mean in practice:</p>
<table>
<thead>
<tr>
<th>Code</th>
<th>Message</th>
<th>Usual cause</th>
</tr>
</thead>
<tbody><tr>
<td>2 / 20</td>
<td>unable to get (local) issuer certificate</td>
<td>Intermediate certificate not sent by the server</td>
</tr>
<tr>
<td>10</td>
<td>certificate has expired</td>
<td>Renewal failed, or the server is still serving the old file</td>
</tr>
<tr>
<td>18</td>
<td>self-signed certificate</td>
<td>Default/snakeoil cert, or Cloudflare Origin CA cert hit directly</td>
</tr>
<tr>
<td>19</td>
<td>self-signed certificate in certificate chain</td>
<td>Wrong chain file, or a TLS-intercepting proxy</td>
</tr>
<tr>
<td>62</td>
<td>hostname mismatch</td>
<td>Certificate doesn't cover this name (www vs apex, missing SAN)</td>
</tr>
</tbody></table>
<p>Code 20 is the classic one behind <code>NET::ERR_CERT_AUTHORITY_INVALID</code> and Firefox's <code>SEC_ERROR_UNKNOWN_ISSUER</code>. The confusing part is that Chrome on a desktop often still loads the page, because it can fetch missing intermediates by itself, while Firefox, Android, curl and API clients fail. So "it works for me" tells you very little.</p>
<p>To see which names the certificate covers and when it expires:</p>
<pre><code class="language-bash">openssl s_client -connect example.com:443 -servername example.com &lt;/dev/null 2&gt;/dev/null \
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName
</code></pre>
<h2>Fixing the chain on Nginx and Apache</h2>
<p>Nginx has no separate chain directive. <code>ssl_certificate</code> must point to a file with the leaf certificate first and the intermediates after it. With Let's Encrypt that's <code>fullchain.pem</code>, not <code>cert.pem</code>:</p>
<pre><code class="language-nginx">server {
    listen 443 ssl;
    http2 on;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
}
</code></pre>
<p>If your CA sent separate files, build the bundle yourself, leaf first:</p>
<pre><code class="language-bash">cat example_com.crt intermediate.crt &gt; example_com_fullchain.crt
</code></pre>
<p>On Apache 2.4.8 and later, <code>SSLCertificateFile</code> can also hold the full chain. Older setups use <code>SSLCertificateChainFile</code>:</p>
<pre><code class="language-apache">&lt;VirtualHost *:443&gt;
    ServerName example.com
    SSLEngine on
    SSLCertificateFile    /etc/letsencrypt/live/example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
&lt;/VirtualHost&gt;
</code></pre>
<p>Check that the key actually belongs to the certificate. Mismatched pairs show up more often than you'd expect after a manual renewal:</p>
<pre><code class="language-bash">openssl x509 -noout -pubkey -in fullchain.pem | sha256sum
openssl pkey -pubout -in privkey.pem | sha256sum
</code></pre>
<p>The two hashes must match. Then test the config and reload:</p>
<pre><code class="language-bash">sudo nginx -t &amp;&amp; sudo systemctl reload nginx
sudo apachectl configtest &amp;&amp; sudo systemctl reload apache2
</code></pre>
<p>Re-run <code>s_client</code> afterwards. You should see depth 1 in the chain and <code>Verify return code: 0</code>.</p>
<h2>When the handshake never happens: ERR_SSL_PROTOCOL_ERROR</h2>
<p><code>ERR_SSL_PROTOCOL_ERROR</code> means the browser and server couldn't even agree to talk TLS. From the server side, check these in order.</p>
<p><strong>1. Is anything actually listening with TLS on 443?</strong></p>
<pre><code class="language-bash">sudo ss -tlnp | grep ':443'
curl -v http://example.com:443/
</code></pre>
<p>If plain HTTP answers on port 443, you've found it. Usually a <code>listen 443;</code> without <code>ssl</code> in Nginx, or a <code>&lt;VirtualHost *:443&gt;</code> without <code>SSLEngine on</code>. OpenSSL reports this as <code>wrong version number</code>, and Firefox shows <code>SSL_ERROR_RX_RECORD_TOO_LONG</code>.</p>
<p><strong>2. Is mod_ssl loaded (Apache)?</strong></p>
<pre><code class="language-bash">apachectl -M | grep ssl
sudo a2enmod ssl &amp;&amp; sudo systemctl restart apache2
</code></pre>
<p><strong>3. Do the protocols overlap?</strong> Modern browsers only accept TLS 1.2 and 1.3. Test each one explicitly:</p>
<pre><code class="language-bash">openssl s_client -connect example.com:443 -servername example.com -tls1_2 &lt;/dev/null
openssl s_client -connect example.com:443 -servername example.com -tls1_3 &lt;/dev/null
</code></pre>
<p>A sensible baseline for Nginx:</p>
<pre><code class="language-nginx">ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
</code></pre>
<p>And for Apache:</p>
<pre><code class="language-apache">SSLProtocol -all +TLSv1.2 +TLSv1.3
SSLHonorCipherOrder off
</code></pre>
<p>If you've copied an old hardened config that lists only a few ciphers, either drop the custom <code>ssl_ciphers</code> line or generate a current one with Mozilla's SSL Configuration Generator. An overly narrow cipher list is a common reason for <code>ERR_SSL_VERSION_OR_CIPHER_MISMATCH</code>.</p>
<p><strong>4. Is something in between?</strong> A load balancer, a hosting panel proxy or a firewall doing TLS inspection can break the handshake before it reaches your web server. Running <code>s_client</code> from the server itself against <code>127.0.0.1</code> with <code>-servername</code> tells you whether the problem is local or upstream.</p>
<h2>Cloudflare SSL modes and the redirect loop</h2>
<p>With Cloudflare proxying (orange cloud), there are two TLS connections: visitor to Cloudflare, and Cloudflare to your origin. The SSL/TLS mode controls the second one:</p>
<ul>
<li><strong>Flexible:</strong> Cloudflare talks to your origin over plain HTTP on port 80. The visitor sees HTTPS, but traffic to your server isn't encrypted.</li>
<li><strong>Full:</strong> Cloudflare connects over HTTPS but accepts any certificate, including self-signed or expired ones.</li>
<li><strong>Full (strict):</strong> Cloudflare connects over HTTPS and requires a valid certificate for the hostname, either from a public CA or Cloudflare's Origin CA.</li>
</ul>
<p>Flexible mode causes most of the <code>ERR_TOO_MANY_REDIRECTS</code> tickets I've seen after someone "turned on SSL". Here's the loop: the origin has an HTTP-to-HTTPS redirect (in Nginx, <code>.htaccess</code>, or a WordPress plugin). Cloudflare requests the page over HTTP, the origin answers with a 301 to HTTPS, the browser follows it to Cloudflare, Cloudflare requests HTTP again, and it repeats forever.</p>
<p>You can confirm it by going straight to the origin and bypassing Cloudflare:</p>
<pre><code class="language-bash">curl -sI --resolve example.com:80:ORIGIN_IP http://example.com/ | grep -i location
curl -sI --resolve example.com:443:ORIGIN_IP https://example.com/
</code></pre>
<p>The real fix is to install a certificate on the origin and switch to <strong>Full (strict)</strong>. Disabling the origin redirect just to make Flexible work hides the problem and leaves the Cloudflare-to-origin leg unencrypted.</p>
<p>For WordPress behind Cloudflare, also make sure WordPress knows the request was HTTPS, or it may generate its own redirects and mixed-content URLs. In <code>wp-config.php</code>, above the "stop editing" line:</p>
<pre><code class="language-php">if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) &amp;&amp; $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
    $_SERVER['HTTPS'] = 'on';
}
</code></pre>
<p>If you switch to Full or Full (strict) and get <strong>Error 525</strong> (handshake failed) or <strong>526</strong> (invalid certificate), run the <code>s_client</code> checks above against the origin IP. It's the same set of problems, just with Cloudflare as the client.</p>
<h2>The Origin CA caveat</h2>
<p>Cloudflare's Origin CA certificates are free, can last up to 15 years, and work well with Full (strict). But <strong>only Cloudflare trusts them.</strong> Browsers don't.</p>
<p>That leads to a recognisable failure. Everything works until someone sets the DNS record to grey cloud (DNS only), pauses Cloudflare, or hits the origin directly through a subdomain that isn't proxied. Then every visitor gets <code>NET::ERR_CERT_AUTHORITY_INVALID</code>, and <code>s_client</code> shows an issuer like <code>CloudFlare Origin SSL Certificate Authority</code>.</p>
<p>So:</p>
<ul>
<li>Use Origin CA only on hostnames that are always proxied.</li>
<li>For anything that might be reached directly (mail hostnames, API subdomains, staging, a site you might move off Cloudflare later), use a public certificate such as Let's Encrypt. It works in Full (strict) too.</li>
<li>If you use Origin CA, consider allowing only Cloudflare IP ranges on 443, so nobody reaches the origin directly by accident.</li>
</ul>
<h2>A quick checklist</h2>
<ol>
<li><code>openssl s_client -servername</code> against the domain, then against the origin IP.</li>
<li>Read the chain depth and the verify code before changing anything.</li>
<li>Serve <code>fullchain</code>, confirm the key matches, run <code>nginx -t</code> or <code>apachectl configtest</code>.</li>
<li>Allow TLS 1.2 and 1.3, and drop outdated cipher lists.</li>
<li>On Cloudflare, use Full (strict) with a real or Origin CA cert, never Flexible plus an origin redirect.</li>
<li>Keep Origin CA certificates behind the orange cloud.</li>
</ol>
<h2>Full step-by-step guides</h2>
<p>I keep longer, error-specific walkthroughs on ErrorRescue:</p>
<ul>
<li><a href="https://errorrescue.com/err-ssl-protocol-error-fix/">ERR_SSL_PROTOCOL_ERROR: server-side fixes</a></li>
<li><a href="https://errorrescue.com/net-err-cert-authority-invalid-fix/">NET::ERR_CERT_AUTHORITY_INVALID: fixing untrusted certificates</a></li>
<li><a href="https://errorrescue.com/sec-error-unknown-issuer/">SEC_ERROR_UNKNOWN_ISSUER in Firefox</a></li>
<li><a href="https://errorrescue.com/err-ssl-version-or-cipher-mismatch/">ERR_SSL_VERSION_OR_CIPHER_MISMATCH</a></li>
<li><a href="https://errorrescue.com/too-many-redirects-after-ssl-fix/">Too many redirects after enabling SSL</a></li>
<li><a href="https://errorrescue.com/error-525-ssl-causes-fix-guide/">Cloudflare Error 525: SSL handshake failed</a></li>
</ul>
]]></content:encoded></item></channel></rss>