How to Configure Nginx for Client Certificate Authentication on Specific Locations Only


1 views

When implementing client certificate authentication in Nginx for specific endpoints while keeping other routes open, many developers encounter unexpected browser behavior. The core issue lies in how browsers handle SSL/TLS negotiation when client certificates are involved.

Most browsers will prompt for client certificates when:

  • The server requests client authentication (even with ssl_verify_client set to optional)
  • The CA list is presented during SSL handshake
  • The browser has matching certificates in its store

Here's a more elegant approach using server blocks and smart SSL configuration:

# Main server block for non-client-cert routes
server {
    listen 443 ssl;
    server_name example.com;
    
    ssl_certificate /etc/nginx/server.crt;
    ssl_certificate_key /etc/nginx/server.key;
    
    location / {
        proxy_pass http://backend;
    }
}

# Dedicated server block for client-cert protected endpoint
server {
    listen 443 ssl;
    server_name example.com;
    
    ssl_certificate /etc/nginx/server.crt;
    ssl_certificate_key /etc/nginx/server.key;
    ssl_client_certificate /etc/nginx/client-ca.crt;
    ssl_verify_client on;  # Changed from optional to required
    
    location /jsonrpc {
        if ($ssl_client_verify != SUCCESS) {
            return 403;
        }
        
        proxy_pass http://localhost:8282/jsonrpc-api;
        proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
        proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
    }
}

For cases where separate server blocks aren't feasible:

map $uri $needs_client_cert {
    ~^/jsonrpc 1;
    default 0;
}

server {
    listen 443 ssl;
    
    ssl_certificate /etc/nginx/server.crt;
    ssl_certificate_key /etc/nginx/server.key;
    ssl_client_certificate /etc/nginx/client-ca.crt;
    ssl_verify_client optional_no_ca;
    
    location / {
        if ($needs_client_cert) {
            set $ssl_verify_required on;
        }
        
        if ($ssl_verify_required = on) {
            set $auth_pass $ssl_client_verify;
        }
        
        if ($auth_pass != SUCCESS) {
            return 403;
        }
        
        proxy_pass http://backend;
    }
}

Different browsers handle client certificates differently:

  • Chrome/Chromium: Will only prompt if site has previously requested cert
  • Safari: More aggressive in prompting when CA list is present
  • Firefox: Respects certificate store organization better

The two-server-block approach:

  • Adds minimal overhead (additional SSL context)
  • Eliminates unnecessary SSL negotiations for regular traffic
  • Provides cleaner separation of security requirements

When implementing client certificate authentication in Nginx, a common requirement is to enforce it only for specific endpoints while keeping other routes accessible without certificates. The standard approach using ssl_verify_client optional with location-based checks works technically, but causes unwanted browser behavior.

The root issue lies in how browsers handle the SSL handshake. When they detect that a server might request a client certificate (due to ssl_verify_client optional), some browsers like Safari and Chrome-on-Android will proactively prompt the user regardless of the actual requested path.

Here's an improved setup that avoids unwanted browser prompts while maintaining security:

server {
  listen 443 ssl;
  
  # Base SSL configuration
  ssl_certificate /etc/nginx/server.crt;
  ssl_certificate_key /etc/nginx/server.key;
  
  # Default - no client cert verification
  ssl_verify_client off;
  
  location /jsonrpc {
    # Enable client cert verification just for this location
    ssl_client_certificate /etc/nginx/client-ca.crt;
    ssl_verify_client on;
    
    if ($ssl_client_verify != SUCCESS) {
      return 403;
    }
    
    proxy_pass http://localhost:8282/jsonrpc-api;
    proxy_read_timeout 90;
  }
  
  # Other locations inherit the default 'off' setting
  location / {
    proxy_pass http://localhost:8080;
  }
}
  • Set ssl_verify_client off at the server level as default
  • Override it to on only in the specific location block
  • Include ssl_client_certificate directive inside the location block
  • The CA certificate is only loaded when needed

Verify the behavior with these curl commands:

# Should work without client cert
curl https://yoursite.com/

# Should require client cert
curl https://yoursite.com/jsonrpc --cert client.crt --key client.key

For more complex scenarios, consider separating the endpoints into different server blocks:

# Non-cert protected server
server {
  listen 443 ssl;
  server_name yoursite.com;
  
  ssl_certificate /etc/nginx/server.crt;
  ssl_certificate_key /etc/nginx/server.key;
  
  location / {
    proxy_pass http://localhost:8080;
  }
}

# Cert-protected server
server {
  listen 443 ssl;
  server_name secure.yoursite.com;
  
  ssl_certificate /etc/nginx/server.crt;
  ssl_certificate_key /etc/nginx/server.key;
  ssl_client_certificate /etc/nginx/client-ca.crt;
  ssl_verify_client on;
  
  location / {
    proxy_pass http://localhost:8282/jsonrpc-api;
  }
}

This completely separates the certificate requirements at the DNS level.

While this solution works for most modern browsers, be aware that:

  • Some corporate environments may have custom CA configurations
  • Mobile browsers might still cache certificate decisions
  • Always test with your target user base's common browsers