{"id":1640,"date":"2025-11-10T21:13:48","date_gmt":"2025-11-10T18:13:48","guid":{"rendered":"https:\/\/www.dchost.com\/blog\/i-stopped-worrying-about-admin-logins-protecting-panels-with-mtls-on-nginx-step%e2%80%91by%e2%80%91step\/"},"modified":"2025-11-10T21:13:48","modified_gmt":"2025-11-10T18:13:48","slug":"i-stopped-worrying-about-admin-logins-protecting-panels-with-mtls-on-nginx-step%e2%80%91by%e2%80%91step","status":"publish","type":"post","link":"https:\/\/www.dchost.com\/blog\/en\/i-stopped-worrying-about-admin-logins-protecting-panels-with-mtls-on-nginx-step%e2%80%91by%e2%80%91step\/","title":{"rendered":"I Stopped Worrying About Admin Logins: Protecting Panels with mTLS on Nginx (Step\u2011by\u2011Step)"},"content":{"rendered":"<div class=\"dchost-blog-content-wrapper\"><p>So there I was, sipping a lukewarm coffee, staring at yet another set of logs full of failed login attempts against an admin panel. You know the kind\u2014IP addresses you\u2019ve never seen, two-second intervals, a parade of username guesses. Rate limiting helps. Strong passwords help. 2FA is great. But here\u2019s the thing: when your admin panel is truly sensitive, you want the door to be invisible unless you\u2019re holding the right key. That was the day I decided to make the door invisible with mutual TLS\u2014mTLS. And honestly, I slept better that night.<\/p>\n<p>Ever had that moment when you realize you\u2019re relying too much on the app to defend itself? I have. And that\u2019s why I want to walk you through a practical, friendly way to put a quiet, powerful shield in front of your admin area using client certificates on Nginx. We\u2019ll build a tiny Certificate Authority, mint client certs, configure Nginx to trust them, roll everything out without locking yourself out, and even talk revocation and troubleshooting. It sounds hardcore, but it feels surprisingly calm once it clicks.<\/p>\n<div id=\"toc_container\" class=\"toc_transparent no_bullets\"><p class=\"toc_title\">\u0130&ccedil;indekiler<\/p><ul class=\"toc_list\"><li><a href=\"#Why_mTLS_Feels_Like_a_VIP_List_for_Your_Admin\"><span class=\"toc_number toc_depth_1\">1<\/span> Why mTLS Feels Like a VIP List for Your Admin<\/a><\/li><li><a href=\"#Designing_Your_Trust_Tiny_CA_or_Full_Toolkit\"><span class=\"toc_number toc_depth_1\">2<\/span> Designing Your Trust: Tiny CA or Full Toolkit?<\/a><\/li><li><a href=\"#The_Lab_Build_a_Tiny_CA_and_a_Client_Certificate_OpenSSL\"><span class=\"toc_number toc_depth_1\">3<\/span> The Lab: Build a Tiny CA and a Client Certificate (OpenSSL)<\/a><ul><li><a href=\"#Step_0_A_safe_workspace\"><span class=\"toc_number toc_depth_2\">3.1<\/span> Step 0: A safe workspace<\/a><\/li><li><a href=\"#Step_1_A_minimal_OpenSSL_config\"><span class=\"toc_number toc_depth_2\">3.2<\/span> Step 1: A minimal OpenSSL config<\/a><\/li><li><a href=\"#Step_2_Create_your_CA_key_and_certificate\"><span class=\"toc_number toc_depth_2\">3.3<\/span> Step 2: Create your CA key and certificate<\/a><\/li><li><a href=\"#Step_3_Generate_a_client_key_and_CSR\"><span class=\"toc_number toc_depth_2\">3.4<\/span> Step 3: Generate a client key and CSR<\/a><\/li><li><a href=\"#Step_4_Sign_the_client_certificate\"><span class=\"toc_number toc_depth_2\">3.5<\/span> Step 4: Sign the client certificate<\/a><\/li><li><a href=\"#Step_5_Export_a_p12_for_easy_browser_import\"><span class=\"toc_number toc_depth_2\">3.6<\/span> Step 5: Export a .p12 for easy browser import<\/a><\/li><\/ul><\/li><li><a href=\"#Nginx_Configuration_Asking_for_the_Badge\"><span class=\"toc_number toc_depth_1\">4<\/span> Nginx Configuration: Asking for the Badge<\/a><ul><li><a href=\"#Approach_A_Dedicated_admin_subdomain_with_mTLS\"><span class=\"toc_number toc_depth_2\">4.1<\/span> Approach A: Dedicated admin subdomain with mTLS<\/a><\/li><li><a href=\"#Approach_B_Only_the_admin_path_requires_mTLS\"><span class=\"toc_number toc_depth_2\">4.2<\/span> Approach B: Only the \/admin path requires mTLS<\/a><\/li><\/ul><\/li><li><a href=\"#Rollout_Without_the_Drama_How_Not_to_Lock_Yourself_Out\"><span class=\"toc_number toc_depth_1\">5<\/span> Rollout Without the Drama: How Not to Lock Yourself Out<\/a><\/li><li><a href=\"#Using_a_Small_CA_Service_step-ca_in_a_Nutshell\"><span class=\"toc_number toc_depth_1\">6<\/span> Using a Small CA Service: step-ca in a Nutshell<\/a><\/li><li><a href=\"#Revocation_Renewal_and_Real-Life_Maintenance\"><span class=\"toc_number toc_depth_1\">7<\/span> Revocation, Renewal, and Real-Life Maintenance<\/a><\/li><li><a href=\"#Testing_Like_You_Mean_It\"><span class=\"toc_number toc_depth_1\">8<\/span> Testing Like You Mean It<\/a><\/li><li><a href=\"#Troubleshooting_Without_Panic\"><span class=\"toc_number toc_depth_1\">9<\/span> Troubleshooting Without Panic<\/a><\/li><li><a href=\"#Attribute-Based_Rules_Let_the_Certificate_Say_Who_You_Are\"><span class=\"toc_number toc_depth_1\">10<\/span> Attribute-Based Rules: Let the Certificate Say Who You Are<\/a><\/li><li><a href=\"#mTLS_Plus_Pairing_with_Other_Protections\"><span class=\"toc_number toc_depth_1\">11<\/span> mTLS Plus: Pairing with Other Protections<\/a><\/li><li><a href=\"#Behind_Proxies_With_WebSockets_and_Other_Real-World_Quirks\"><span class=\"toc_number toc_depth_1\">12<\/span> Behind Proxies, With WebSockets, and Other Real-World Quirks<\/a><\/li><li><a href=\"#A_Quick_Nod_to_Documentation\"><span class=\"toc_number toc_depth_1\">13<\/span> A Quick Nod to Documentation<\/a><\/li><li><a href=\"#Security_Hygiene_Keep_the_Boring_Stuff_Boring\"><span class=\"toc_number toc_depth_1\">14<\/span> Security Hygiene: Keep the Boring Stuff Boring<\/a><\/li><li><a href=\"#Wrap-Up_A_Quiet_Door_Is_a_Beautiful_Thing\"><span class=\"toc_number toc_depth_1\">15<\/span> Wrap-Up: A Quiet Door Is a Beautiful Thing<\/a><ul><li><a href=\"#Useful_references\"><span class=\"toc_number toc_depth_2\">15.1<\/span> Useful references<\/a><\/li><\/ul><\/li><\/ul><\/div>\n<h2 id=\"section-1\"><span id=\"Why_mTLS_Feels_Like_a_VIP_List_for_Your_Admin\">Why mTLS Feels Like a VIP List for Your Admin<\/span><\/h2>\n<p>When we browse the web, our browser checks the server\u2019s identity with TLS. Mutual TLS adds a second handshake: the server also asks the client to prove who it is with a certificate. Think of it like a velvet rope: the server says, \u201cI\u2019ll show you who I am, but I only open this door for people who present a badge I trust.\u201d No badge? You\u2019re not even getting to the login screen.<\/p>\n<p>For admin panels, this is gold. It cuts out the noise\u2014no brute force, no random scans, no guessing. The best part? Your app doesn\u2019t work any harder. Nginx handles the check before a single line of your admin code runs. It\u2019s quiet security. In my experience, the biggest trade-off is logistical: issuing certificates, distributing them, and keeping a simple process for revocation and renewal. But once you have a routine, it\u2019s like a seatbelt\u2014fasten it and forget it.<\/p>\n<h2 id=\"section-2\"><span id=\"Designing_Your_Trust_Tiny_CA_or_Full_Toolkit\">Designing Your Trust: Tiny CA or Full Toolkit?<\/span><\/h2>\n<p>Before we touch Nginx, we make a tiny decision: who issues your client certificates? You have two realistic approaches. First, a minimal \u201cad hoc\u201d CA using OpenSSL that you keep safe on an admin laptop or a secure VM. This is straightforward and perfect for small teams. Second, a lightweight CA service like step-ca that gives you niceties like simple enrollment flows and expiration management.<\/p>\n<p>Both work. If you\u2019re just starting, I usually recommend a mini-CA with OpenSSL because it teaches the essentials while staying simple. Later, if your team grows and you want smoother onboarding, consider a small CA service that speaks modern workflows. You don\u2019t need enterprise everything\u2014just something that helps you mint, renew, and revoke cleanly.<\/p>\n<p>A few practical tips I\u2019ve learned the hard way: name your certs clearly with the person\u2019s name and an expiration; keep keys off shared machines; prefer short-lived certs for contractors; and have a revocation plan ready before you need it. You\u2019ll thank yourself later.<\/p>\n<h2 id=\"section-3\"><span id=\"The_Lab_Build_a_Tiny_CA_and_a_Client_Certificate_OpenSSL\">The Lab: Build a Tiny CA and a Client Certificate (OpenSSL)<\/span><\/h2>\n<h3><span id=\"Step_0_A_safe_workspace\">Step 0: A safe workspace<\/span><\/h3>\n<p>Create a clean directory to keep your CA materials. You\u2019ll store sensitive keys here\u2014treat it like a safe.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">mkdir -p ~\/mtls-ca\/{certs,crl,newcerts,private}\nchmod 700 ~\/mtls-ca\/private\ntouch ~\/mtls-ca\/index.txt\necho 1000 &gt; ~\/mtls-ca\/serial\n<\/code><\/pre>\n<h3><span id=\"Step_1_A_minimal_OpenSSL_config\">Step 1: A minimal OpenSSL config<\/span><\/h3>\n<p>Drop a basic config file so OpenSSL knows how to behave. Keep it human\u2011readable and short.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">cat &gt; ~\/mtls-ca\/openssl.cnf &lt;&lt;'EOF'\n[ ca ]\ndefault_ca = CA_default\n\n[ CA_default ]\ndir               = ~\/mtls-ca\ncerts             = $dir\/certs\ncrl_dir           = $dir\/crl\ndatabase          = $dir\/index.txt\nnew_certs_dir     = $dir\/newcerts\nserial            = $dir\/serial\nprivate_key       = $dir\/private\/ca.key.pem\ncertificate       = $dir\/certs\/ca.cert.pem\ncrlnumber         = $dir\/crlnumber\ncrl               = $dir\/crl\/ca.crl.pem\ncrl_extensions    = crl_ext\ndefault_crl_days  = 30\n\ndefault_md        = sha256\npolicy            = policy_loose\nemail_in_dn       = no\ncopy_extensions   = copy\n\n[ policy_loose ]\ncommonName             = supplied\nstateOrProvinceName    = optional\ncountryName            = optional\nemailAddress           = optional\norganizationName       = optional\norganizationalUnitName = optional\n\n[ req ]\ndistinguished_name = req_distinguished_name\nx509_extensions    = v3_ca\nprompt = no\n\n[ req_distinguished_name ]\ncommonName = My Tiny Admin CA\n\n[ v3_ca ]\nsubjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid:always,issuer\nbasicConstraints = CA:true\nkeyUsage = keyCertSign, cRLSign\n\n[ usr_cert ]\nbasicConstraints = CA:false\nkeyUsage = digitalSignature, keyEncipherment\nextendedKeyUsage = clientAuth\nsubjectAltName = @alt_names\n\n[ alt_names ]\nemail.1 = admin@example.com\n\n[ crl_ext ]\nauthorityKeyIdentifier=keyid:always\nEOF\n<\/code><\/pre>\n<h3><span id=\"Step_2_Create_your_CA_key_and_certificate\">Step 2: Create your CA key and certificate<\/span><\/h3>\n<p>This is the anchor of trust. Guard the key and back it up safely.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">openssl genrsa -out ~\/mtls-ca\/private\/ca.key.pem 4096\nchmod 600 ~\/mtls-ca\/private\/ca.key.pem\nopenssl req -config ~\/mtls-ca\/openssl.cnf -key ~\/mtls-ca\/private\/ca.key.pem \n  -new -x509 -days 3650 -sha256 -out ~\/mtls-ca\/certs\/ca.cert.pem\n<\/code><\/pre>\n<h3><span id=\"Step_3_Generate_a_client_key_and_CSR\">Step 3: Generate a client key and CSR<\/span><\/h3>\n<p>Make one per person. I like naming them clearly\u2014no mystery files six months later.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">openssl genrsa -out ~\/mtls-ca\/private\/alex.key.pem 4096\nopenssl req -new -key ~\/mtls-ca\/private\/alex.key.pem \n  -subj &quot;\/CN=Alex Admin&quot; -out ~\/mtls-ca\/alex.csr.pem\n<\/code><\/pre>\n<h3><span id=\"Step_4_Sign_the_client_certificate\">Step 4: Sign the client certificate<\/span><\/h3>\n<p>This is where your CA blesses the key. Notice we reuse the usr_cert profile, which enables client authentication.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">openssl ca -config ~\/mtls-ca\/openssl.cnf -extensions usr_cert \n  -days 365 -notext -md sha256 -in ~\/mtls-ca\/alex.csr.pem \n  -out ~\/mtls-ca\/certs\/alex.cert.pem\n<\/code><\/pre>\n<h3><span id=\"Step_5_Export_a_p12_for_easy_browser_import\">Step 5: Export a .p12 for easy browser import<\/span><\/h3>\n<p>Browsers love PKCS#12. This bundles the cert and key into a single file with a passphrase. Give each user their own package securely.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">openssl pkcs12 -export -inkey ~\/mtls-ca\/private\/alex.key.pem \n  -in ~\/mtls-ca\/certs\/alex.cert.pem \n  -certfile ~\/mtls-ca\/certs\/ca.cert.pem \n  -out ~\/mtls-ca\/alex.p12\n<\/code><\/pre>\n<p>When you import this in Chrome, Firefox, or Safari, the browser will prompt when a site asks for a client certificate. It\u2019s almost magical the first time you see it work.<\/p>\n<h2 id=\"section-4\"><span id=\"Nginx_Configuration_Asking_for_the_Badge\">Nginx Configuration: Asking for the Badge<\/span><\/h2>\n<p>Now we wire up Nginx to request and verify client certificates. There are two ways I\u2019ve rolled this out. If your admin panel lives on a dedicated subdomain (like admin.example.com), make mTLS mandatory for that whole host\u2014it\u2019s tidy and clean. If your admin panel is a path on your main domain, you can require mTLS only for that path. Both approaches are fine; pick the one that fits your reality.<\/p>\n<h3><span id=\"Approach_A_Dedicated_admin_subdomain_with_mTLS\">Approach A: Dedicated admin subdomain with mTLS<\/span><\/h3>\n<pre class=\"language-nginx line-numbers\"><code class=\"language-nginx\">server {\n    listen 443 ssl http2;\n    server_name admin.example.com;\n\n    # Your normal server TLS certs (e.g., from Let's Encrypt)\n    ssl_certificate     \/etc\/ssl\/certs\/server.crt;\n    ssl_certificate_key \/etc\/ssl\/private\/server.key;\n\n    # Ask clients for a certificate and verify against your CA\n    ssl_client_certificate \/etc\/nginx\/client_ca\/ca.cert.pem;\n    ssl_verify_client on;\n    ssl_verify_depth 2;\n\n    # Optional but recommended: provide a CRL to support revocation\n    ssl_crl \/etc\/nginx\/client_ca\/ca.crl.pem;\n\n    # Good hygiene for TLS (keep your baseline modern)\n    ssl_protocols TLSv1.2 TLSv1.3;\n\n    # Log what happened for visibility\n    log_format mtls '$remote_addr - $remote_user [$time_local] ' \n                    '&quot;$request&quot; $status $body_bytes_sent ' \n                    '&quot;$http_referer&quot; &quot;$http_user_agent&quot; ' \n                    'ssl_client_verify=$ssl_client_verify ' \n                    'ssl_client_s_dn=&quot;$ssl_client_s_dn&quot;';\n    access_log \/var\/log\/nginx\/admin_access.log mtls;\n\n    location \/ {\n        proxy_pass http:\/\/127.0.0.1:8080; # your admin app\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Client-Verify $ssl_client_verify;\n        proxy_set_header X-Client-DN $ssl_client_s_dn;\n    }\n}\n<\/code><\/pre>\n<p>With this, you can\u2019t reach the admin without presenting a valid client cert signed by your CA. It\u2019s deliciously quiet\u2014bots just fall off the cliff.<\/p>\n<h3><span id=\"Approach_B_Only_the_admin_path_requires_mTLS\">Approach B: Only the \/admin path requires mTLS<\/span><\/h3>\n<p>If your public site lives on the same host, you can make Nginx ask for a certificate only when visitors hit a protected path. Setting it to <strong>optional<\/strong> at the server block, then enforcing at the location, keeps the rest of the site public.<\/p>\n<pre class=\"language-nginx line-numbers\"><code class=\"language-nginx\">server {\n    listen 443 ssl http2;\n    server_name example.com;\n\n    ssl_certificate     \/etc\/ssl\/certs\/server.crt;\n    ssl_certificate_key \/etc\/ssl\/private\/server.key;\n\n    ssl_client_certificate \/etc\/nginx\/client_ca\/ca.cert.pem;\n    ssl_verify_client optional;  # ask for a cert but don't fail yet\n    ssl_verify_depth 2;\n\n    ssl_crl \/etc\/nginx\/client_ca\/ca.crl.pem;  # if you use CRLs\n\n    location \/ {\n        # public site\n        proxy_pass http:\/\/127.0.0.1:8080;\n    }\n\n    location \/admin\/ {\n        # Enforce success: only let requests with a valid client cert through\n        if ($ssl_client_verify != SUCCESS) { return 403; }\n        proxy_pass http:\/\/127.0.0.1:8080;\n        proxy_set_header X-Client-Verify $ssl_client_verify;\n        proxy_set_header X-Client-DN $ssl_client_s_dn;\n    }\n}\n<\/code><\/pre>\n<p>Yes, there\u2019s an <strong>if<\/strong> in a location block. In this specific pattern, it\u2019s a handy, simple gate. If you prefer to avoid <strong>if<\/strong>, you can use a <strong>map<\/strong> directive at http-level to translate <em>SUCCESS<\/em> into a boolean and gate with <strong>return<\/strong> based on that variable. The main idea is the same.<\/p>\n<p>When you want to dive into directive details, the official Nginx docs for the <a href=\"http:\/\/nginx.org\/en\/docs\/http\/ngx_http_ssl_module.html#ssl_client_certificate\" rel=\"nofollow noopener\" target=\"_blank\">ssl_client_certificate and ssl_verify_client directives<\/a> are short and to the point.<\/p>\n<h2 id=\"section-5\"><span id=\"Rollout_Without_the_Drama_How_Not_to_Lock_Yourself_Out\">Rollout Without the Drama: How Not to Lock Yourself Out<\/span><\/h2>\n<p>I\u2019ve seen smart people lock themselves out by turning mTLS on at 5 PM Friday. Don\u2019t be that person. Here\u2019s a calm rollout path that\u2019s worked consistently for me.<\/p>\n<p>First, enable mTLS on a staging host or a different port. Import your own p12 file into your browser and verify you can get in. Then invite one teammate to confirm. Once it feels solid, flip the switch on your real admin host, but keep a fallback window\u2014either a temporary IP allowlist or a side door with basic auth that you disable after a week. And keep SSH access to revert quickly if you misconfigure something.<\/p>\n<p>Another thing: put a friendly 403 page in place for the admin path that explains, in plain language, that a client certificate is required. You\u2019ll save yourself a support thread with your own team. And store a backup of your CA key offline. If your CA key vanishes, you haven\u2019t just lost a key; you\u2019ve lost the power to mint trust.<\/p>\n<h2 id=\"section-6\"><span id=\"Using_a_Small_CA_Service_step-ca_in_a_Nutshell\">Using a Small CA Service: step-ca in a Nutshell<\/span><\/h2>\n<p>When your team grows and issuing certs by hand gets old, <strong>step-ca<\/strong> gives you a modern experience without turning your life into PKI paperwork. You can set up your own CA service and issue short-lived client certs with simple commands or a web flow. The documentation for <a href=\"https:\/\/smallstep.com\/docs\/step-ca\" rel=\"nofollow noopener\" target=\"_blank\">step-ca<\/a> is friendly and practical. If you prefer to keep everything local and minimal, your OpenSSL mini-CA is still perfectly fine. I\u2019ve used both; the right choice is the one your team will actually use consistently.<\/p>\n<h2 id=\"section-7\"><span id=\"Revocation_Renewal_and_Real-Life_Maintenance\">Revocation, Renewal, and Real-Life Maintenance<\/span><\/h2>\n<p>People leave teams, laptops get replaced, and certificates expire. Plan for these little moments and you\u2019ll stay calm.<\/p>\n<p>Revocation with Nginx is usually done via a CRL (Certificate Revocation List). You publish a file listing bad certs and tell Nginx to check it. It\u2019s simple and reliable for small teams. Here\u2019s a tiny example of revoking and generating a new CRL with OpenSSL.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\"># Revoke a certificate by its serial (OpenSSL will prompt during the revoke)\nopenssl ca -config ~\/mtls-ca\/openssl.cnf -revoke ~\/mtls-ca\/newcerts\/1001.pem\n\n# Bump and generate a CRL\n[ -f ~\/mtls-ca\/crlnumber ] || echo 2000 &gt; ~\/mtls-ca\/crlnumber\nopenssl ca -config ~\/mtls-ca\/openssl.cnf -gencrl -out ~\/mtls-ca\/crl\/ca.crl.pem\n\n# Copy CRL to Nginx and reload\nsudo cp ~\/mtls-ca\/crl\/ca.crl.pem \/etc\/nginx\/client_ca\/ca.crl.pem\nsudo nginx -s reload\n<\/code><\/pre>\n<p>Renewal is straightforward: generate a new cert for the person, export a new p12, and ask them to import it. If you pick shorter lifetimes (say, 6\u201312 months), you get natural rotation. Give folks a gentle reminder a couple weeks before expiry\u2014no one likes a surprise block.<\/p>\n<h2 id=\"section-8\"><span id=\"Testing_Like_You_Mean_It\">Testing Like You Mean It<\/span><\/h2>\n<p>Browser tests are the real-world test, but I always like to double-check with the command line. It removes browser quirks from the equation and gives you clear signals.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\"># Test with PEM key and cert\ncurl -v --cert ~\/mtls-ca\/certs\/alex.cert.pem --key ~\/mtls-ca\/private\/alex.key.pem \n  https:\/\/admin.example.com\/\n\n# Test with PKCS#12 bundle (will prompt for passphrase)\ncurl -v --cert ~\/mtls-ca\/alex.p12:yourPass --cert-type P12 https:\/\/admin.example.com\/\n<\/code><\/pre>\n<p>If you see a 403 or 400 without much context, Nginx\u2019s error logs are your next stop. Also, consider enabling a custom log format that records <strong>$ssl_client_verify<\/strong> and <strong>$ssl_client_s_dn<\/strong> so you can quickly see who got in and who didn\u2019t.<\/p>\n<h2 id=\"section-9\"><span id=\"Troubleshooting_Without_Panic\">Troubleshooting Without Panic<\/span><\/h2>\n<p>I\u2019ve tripped over the same handful of issues so many times that I can spot them from across the room. Here\u2019s the greatest hits and how I untangle them.<\/p>\n<p>First, the \u201cNo required <a href=\"https:\/\/www.dchost.com\/ssl\">SSL certificate<\/a> was sent\u201d message. This usually means the browser didn\u2019t present a cert at all. In path-based setups, confirm you used <strong>optional<\/strong> at the server level and enforced only at the admin path. And make sure your browser actually imported the p12 and is offering it\u2014sometimes a browser restart is the silly fix.<\/p>\n<p>Second, \u201ccertificate verify failed.\u201d Nine times out of ten, Nginx is missing the correct CA certificate or the chain. Point <strong>ssl_client_certificate<\/strong> at the CA that issued your client certs. If you have intermediates, include them in the file as a chain.<\/p>\n<p>Third, revocation not taking effect. Did you update the CRL and copy it to the path Nginx reads? Did you reload Nginx? CRLs expire, so regenerate them periodically. Yes, it\u2019s one more cron job, but it\u2019s worth it.<\/p>\n<p>If you need to deep-dive a handshake, I still use <strong>openssl s_client<\/strong> to peek under the hood. It\u2019s old-school, but seeing the exact handshake chatter can save your afternoon.<\/p>\n<h2 id=\"section-10\"><span id=\"Attribute-Based_Rules_Let_the_Certificate_Say_Who_You_Are\">Attribute-Based Rules: Let the Certificate Say Who You Are<\/span><\/h2>\n<p>Sometimes you want more than \u201chas a valid cert.\u201d Maybe you only want certs with a specific CN, or you want to read an email SAN and log it. Nginx exposes several variables like <strong>$ssl_client_s_dn<\/strong> (the subject) and a bundle of <strong>$ssl_client_*<\/strong> fields that you can use for decisions.<\/p>\n<p>For example, if you want to allow only certificates with \u201cAdmin\u201d in the CN, you can map that to a flag and enforce it at the location. It\u2019s a gentle way to layer authorization onto authentication.<\/p>\n<pre class=\"language-nginx line-numbers\"><code class=\"language-nginx\">http {\n    map $ssl_client_s_dn $mtls_admin {\n        default 0;\n        ~CN=.*Admin.* 1;\n    }\n\n    server {\n        listen 443 ssl;\n        server_name admin.example.com;\n\n        ssl_certificate     \/etc\/ssl\/certs\/server.crt;\n        ssl_certificate_key \/etc\/ssl\/private\/server.key;\n        ssl_client_certificate \/etc\/nginx\/client_ca\/ca.cert.pem;\n        ssl_verify_client on;\n\n        location \/ {\n            if ($mtls_admin = 0) { return 403; }\n            proxy_pass http:\/\/127.0.0.1:8080;\n        }\n    }\n}\n<\/code><\/pre>\n<p>Don\u2019t go wild with regex rules in production without testing. But it\u2019s comforting to know you can enforce \u201conly this group\u201d right at the edge.<\/p>\n<h2 id=\"section-11\"><span id=\"mTLS_Plus_Pairing_with_Other_Protections\">mTLS Plus: Pairing with Other Protections<\/span><\/h2>\n<p>mTLS is an elegant lock, but it plays nicely with other defenses. A classic pattern is to keep your application\u2019s own authentication and authorization in place even after mTLS\u2014so even if someone accidentally gets a client cert, they still face a second gate. I\u2019ve also paired mTLS with rate limiting, just to be a good citizen against unexpected spikes or weird clients. And if you love an extra layer, you can add \u201csatisfy any\u201d with an IP allowlist during a gentle rollout, then remove it once you trust the cert distribution process fully.<\/p>\n<p>If this whole conversation about keys and trust feels familiar, that\u2019s because it is. On the server side, I\u2019ve used the same mindset for SSH hardening\u2014CAs, short-lived keys, and calm rotation. If that sparks your curiosity, I shared my playbook in <a href=\"https:\/\/www.dchost.com\/blog\/en\/vpste-ssh-guvenligi-nasil-saglamlasir-fido2-anahtarlari-ssh-ca-ve-rotasyonun-sicacik-yolculugu\/\">VPS SSH Hardening Without the Drama: FIDO2 Hardware Keys, SSH CA, and Safe Key Rotation Step-by-Step<\/a>, and the workflow parallels are uncanny.<\/p>\n<h2 id=\"section-12\"><span id=\"Behind_Proxies_With_WebSockets_and_Other_Real-World_Quirks\">Behind Proxies, With WebSockets, and Other Real-World Quirks<\/span><\/h2>\n<p>One thing I learned while rolling this out behind a CDN: if the CDN terminates TLS, you can\u2019t do mTLS at your origin unless the CDN supports it and passes a signal downstream. Some providers offer mTLS at the edge so only verified clients get through. If you\u2019re going direct-to-origin with Nginx, you\u2019re in control and life is simpler.<\/p>\n<p>WebSockets? They\u2019re fine. As long as the initial handshake passes through your mTLS gate, the connection stays alive just like any other TLS session. HTTP\/2? Also fine. Nginx handles this neatly; the certificate verification happens before your app ever sees a request.<\/p>\n<p>For HSTS, consider the user experience. If your admin is on a different subdomain, enabling HSTS with the usual max-age is great\u2014but don\u2019t preload unless you\u2019re absolutely sure about your subdomain choices. It\u2019s a one-way door that can be tricky to unwind.<\/p>\n<h2 id=\"section-13\"><span id=\"A_Quick_Nod_to_Documentation\">A Quick Nod to Documentation<\/span><\/h2>\n<p>When I want to sanity-check my memory or peek at a directive nuance, I keep two bookmarks close. The Nginx SSL module docs for the <a href=\"http:\/\/nginx.org\/en\/docs\/http\/ngx_http_ssl_module.html#ssl_client_certificate\" rel=\"nofollow noopener\" target=\"_blank\">client certificate directives<\/a> and OpenSSL\u2019s <a href=\"https:\/\/www.openssl.org\/docs\/man1.1.1\/man1\/openssl-pkcs12.html\" rel=\"nofollow noopener\" target=\"_blank\">pkcs12 reference<\/a> are short, direct, and rarely surprise me. They\u2019ve saved me from typos and fuzzy recollections more times than I can count.<\/p>\n<h2 id=\"section-14\"><span id=\"Security_Hygiene_Keep_the_Boring_Stuff_Boring\">Security Hygiene: Keep the Boring Stuff Boring<\/span><\/h2>\n<p>Some of the best security wins are a little boring, and that\u2019s okay. Keep your server TLS configs modern, your CA key backed up and protected, and your CRL fresh. Give your team a short handbook: where to get their p12, how to import it, what the 403 page means, and who to ping for a replacement. The less your process depends on a single person, the calmer your future self will be.<\/p>\n<p>If you\u2019re curious about cleaner PKI at scale, tools like step-ca can make certificate lifecycle a normal task rather than a once-a-year fire drill. And if you ever end up delegating authorization decisions to an internal service, Nginx\u2019s <strong>auth_request<\/strong> module can forward mTLS-derived identity to a tiny endpoint that says yes or no. It\u2019s nice to know you can grow into that without rewriting your edge.<\/p>\n<h2 id=\"section-15\"><span id=\"Wrap-Up_A_Quiet_Door_Is_a_Beautiful_Thing\">Wrap-Up: A Quiet Door Is a Beautiful Thing<\/span><\/h2>\n<p>Let\u2019s circle back. You want your admin panel to be a place only your team can reach\u2014not a neon sign in a noisy alley. Mutual TLS with Nginx gives you that quiet door. You mint a client certificate for each person, Nginx asks to see it, and only then does your app wake up. It\u2019s simple to understand, honest to manage, and wonderfully effective against random scans and brute force noise.<\/p>\n<p>Start small: build a tiny CA, mint a test cert, and stand up a staging host. Verify in your browser, check with curl, and watch the logs. Then roll out gently to production with a safety rope\u2014an IP window or a side door\u2014while everyone imports their p12. Add revocation and a reminder for renewals, and you\u2019ve got yourself a calm, durable routine.<\/p>\n<p>I remember the first week after turning on mTLS for an especially sensitive dashboard. The logs got quiet. The app felt safer. And my coffee finally stayed warm. Hope this was helpful! If you try it and hit a snag, keep notes\u2014you\u2019ll be the person your team thanks later. See you in the next post.<\/p>\n<hr \/>\n<h3><span id=\"Useful_references\">Useful references<\/span><\/h3>\n<p>\n&#8211; Nginx client certificate directives: <a href=\"http:\/\/nginx.org\/en\/docs\/http\/ngx_http_ssl_module.html#ssl_client_certificate\" rel=\"nofollow noopener\" target=\"_blank\">official docs<\/a><br \/>\n&#8211; OpenSSL pkcs12 usage: <a href=\"https:\/\/www.openssl.org\/docs\/man1.1.1\/man1\/openssl-pkcs12.html\" rel=\"nofollow noopener\" target=\"_blank\">reference<\/a><br \/>\n&#8211; step-ca overview: <a href=\"https:\/\/smallstep.com\/docs\/step-ca\" rel=\"nofollow noopener\" target=\"_blank\">documentation<\/a><\/p>\n<\/div>","protected":false},"excerpt":{"rendered":"<p>So there I was, sipping a lukewarm coffee, staring at yet another set of logs full of failed login attempts against an admin panel. You know the kind\u2014IP addresses you\u2019ve never seen, two-second intervals, a parade of username guesses. Rate limiting helps. Strong passwords help. 2FA is great. But here\u2019s the thing: when your admin [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":1642,"comment_status":"","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[26],"tags":[],"class_list":["post-1640","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-teknoloji"],"_links":{"self":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts\/1640","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/comments?post=1640"}],"version-history":[{"count":0,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts\/1640\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/media\/1642"}],"wp:attachment":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/media?parent=1640"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/categories?post=1640"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/tags?post=1640"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}