You are currently viewing Estonian ID card authentication using Nginx and CORS

Estonian ID card authentication using Nginx and CORS

Authentication in web apps using an Estonian ID card with Nginx is pretty straightforward (although quite unstable).

Initial setup

Simple ID card authentication boils down to:

  • Enable One-Way TLS on your main domain (e.g. app.example.com)
  • Enable Two-Way TLS on a secondary domain (e.g. id.example.com) using the ID card certificate chain.
  • Redirect user from main site to the id subdomain to initiate authentication.

There are a lot of additional things that you should configure to make it secure, like checking the certificate validity using CRL since Nginx is not doing OCSP checks and configuring secure TLS versions and cypher suites. I’m not going to talk about that stuff in this post, but you can read more about that here (in Estonian).

This is the simplified version of the needed Nginx configuration:

server {
    listen 443 ssl;
    server_name app.example.com;
    ssl_certificate /tmp/fullchain.pem;
    ssl_certificate_key /tmp/privkey.pem;
    ssl_dhparam /tmp/dhparam.pem;
}
server {
    listen 443 ssl;
    server_name id.example.com;
    ssl_certificate /tmp/fullchain.pem;
    ssl_certificate_key /tmp/privkey.pem;
    # Certificate chain
    ssl_client_certificate /tmp/id.crt;
    ssl_verify_client on;
    ssl_verify_depth 2;
    ssl_dhparam /tmp/dhparam.pem;
}

We set up two servers listening on port 443, both using the same certificate chain and key.

NB! The certificate needs to be the same on both servers and it should have both domain names as SAN.

Why do we have two domains at all, why not just use one? The issue is with TLS renegotiation: when a user visits your main site, TLS connection is initiated using one way TLS and if at a later time you need the user to authenticate with an ID card, the connection needs to be renegotiated to enable two-way TLS. Older browsers do not support renegotiating with the newer servers because a change in the spec, so to support those older browsers, it is common to just redirect to an alternate domain and configure it to only support two-way TLS so when the connection is first initiated, we always check the ID card.

Authentication methods

In the most common ID card authentication setup, we redirect the user to the id subdomain and after the ID card authentication we set a session cookie from the id subdomain using the Domain attribute (in our case example.com). When we redirect back to the main domain we already have the cookie available and a session set up successfully.

This works fine, but what if we really do not want this redirect? For example we have a SPA and this redirect is just bad UX. In that case we can turn to CORS. Instead of redirecting the browser to another subdomain, we can just do an async HTTP request to the id subdomain and get the cookie without ever leaving the page.

This is quite easy to set up. We just need to remember that when we don’t have a simple request, the CORS request is preflighted and only then is the actual request made. The problem is that according to the Fetch spec, preflight OPTIONS requests should not contain TLS certificates (which Chrome ignores) so two-way TLS connection is attempted during the preflight request and it will fail, causing the actual request to be not attempted at all. Solution is to make the request “simple” and all is well.

NB! Actually there is one gotcha: if your CORS request is POST, then you need to execute a single GET request first! Read more about it here.

Non-simple requests

What if I can’t make the request “simple”? For example we have a stateless back-end, the user is already authenticated and has a JWT token and now we would like to check if the user has an Estonian ID card as well for extra security. We need to pass the token to the back-end using an “Authorization” header, but this is not part of the allowed headers for the request to be regarded as “simple” so a preflight OPTIONS request will be sent and it will fail because it can’t set up two-way TLS connection.

There is a way to get around this limitation: we specify that the client verification is optional and only do the certificate check when the request is POST (or GET because of previously described issue). So in this case the OPTIONS request will not fail and the client cert is still sent with the GET/POST request. We just need to handle the case where for some reason the request does not have the certificate (user cancelled, id card reader faulty etc.).

server {
    listen 443 ssl;
    server_name app.example.com;
    ssl_certificate /tmp/fullchain.pem;
    ssl_certificate_key /tmp/privkey.pem;
    ssl_dhparam /tmp/dhparam.pem;
}
map $http_origin $cors_header {
  default "";
  "~^https?:\/\/(app\.example\.com|example\.com)" "$http_origin";
}
map "$request_method:$ssl_client_verify" $is_ssl_verification_failed {
    default 1;
    "POST:SUCCESS" 0;
    "GET:SUCCESS" 0;
}
server {
    listen 443 ssl;
    server_name id.example.com;
...
    ssl_verify_client optional;
...
    location / {
        if ($request_method = OPTIONS) {
            add_header 'Access-Control-Allow-Origin' "$cors_header";
            add_header 'Access-Control-Allow-Methods' 'POST, PUT, GET, OPTIONS, DELETE';
            add_header 'Access-Control-Max-Age' 3600;
            add_header 'Access-Control-Allow-Credentials' 'true';
            add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
            add_header 'Content-Type' 'text/plain; charset=utf-8';
            add_header 'Content-Length' 0;
            return 204;
        }
        if ($is_ssl_verification_failed) {
          return 403;
        }
        # Your GET/POST handling goes here
    }
}

Configuration has following changes:

  • ssl_verify_client directive is set to optional to allow requests that do not have a certificate, but still enable two-way TLS.
  • We define $is_ssl_verification_failed as a combination of request method and certificate validation result so it is 1 when we have a GET/POST request and the validation was successful, otherwise 0.
  • When the request method is OPTIONS, which is the preflight request, we return a successful response (with all the needed access control headers for the next request).
  • When the $is_ssl_verification_failed variable is not 1 we return 403, otherwise we let the request continue. For example we can proxy it to an upstream server and we can use $ssl_client_verify and $ssl_client_escaped_cert variables to pass the certificate with the verification status to upstream using headers and do extra validation there or just read out the needed information from the certificate.

Conclusion

Estonian ID card infrastructure is pretty powerful but for developers it is a lot of hassle to get it working correctly and securely, so if you see any shortcomings or security issues in this solution let us know!