Certificate-based client authentication

Sajjad Rad
9 min readFeb 17, 2024
Photo by Robert Anasch on Unsplash

Authorization is always a challenge in the service-to-service call. In this article, we will explore the possibility of authorizing clients using certificates instead of JWT tokens.

Normally, in a service-to-service call, JWT tokens are used to let clients access the protected resources, but what if the server that gives out those tokens isn’t working? Then clients can’t access what they need, which could be a big problem.

First, let’s review the JWT token authorization flow:

Client Authorization using a JWT token

Alice wants to get access to a protected resource (here a user) on Bob. But first Bob should authorize Alice to ensure it has access to the protected resource.

  1. Alice sends a request to the Authorization server to obtain a valid JWT token including resource access scopes on the Bob (e.g. bob.user.read). The request must include a signature signed by Alice’s private key, so the server can trust the client.
  2. Then Alice sends a request to Bob along with the client’s (Alice) token.
  3. Bob validates the signature of Alice’s token using the authorization server's public key or introspect it through a network call.
  4. Then Bob checks the scopes in the token to ensure Alice has permission to read the user’s data.

What if the authorization server is unavailable? Alice is unable to obtain a JWT token to call Bob.

x509 Certificate to authorize the client as a fallback strategy

This hard dependency on the authorization server could be mitigated by using an x509 certificate to authorize the client.

In this solution, the authorization server is a certificate authority (CA) and issues an x509 certificate with scopes as an Extra Extension.

Now let’s review the flow again with this solution:

Client Authorization using a Certificate
  1. CA (here authorization server) issues an x509 Certificate for Alice. including Alice’s public key and all Alice’s scopes as an Extra Extension
  2. Alice adds the base64-encoded certificate in the request’s header to Bob
  3. Alice signs the request using its private key. It calculates the SHA-256 hash of the request parameter, then uses the private key to sign the hash, and finally encodes it using base64. If you are interested, you can read this post about HMAC signature (a similar approach but with a symmetric encryption method).
  4. Bob receives Alice’s request and reads the client certificate from the header.
  5. In the first step, it validates the signature of the certificate using the CA certificate. Until here, Bob knows Alice’s certificate is a valid certificate signed by CA (authorization server)
  6. Bob must ensure the client is Alice. So it reads Alice’s public key from the client certificate in the request’s header.
  7. Bob attempts to reconstruct the request parameters, computes the SHA-256 hash of these parameters, and subsequently validates the request’s signature. This validation involves decrypting the signature using Alice’s public key and comparing it with the hashed request parameters.
  8. If everything’s fine, Bob fetches the scopes from Alice’s certificate (in the request’s header) and authorizes the request action.

Use certificate as Fallback

We’re not suggesting replacing a JWT token with certificate-based authentication. However, using a certificate could serve as a fallback solution in cases where the authorization server is inaccessible

An SDK can hide all implementation details to switch between JWT and certificate by using a circuit breaker. The client opens a circuit to use the certificate to call the resource server when the authorization server is still unavailable after many retries. In the background, it will ping the authorization server to close the circuit when it’s available again.

Certificate expiration time and scope changes

A certificate with a longer expiration time (1–5 years) than a JWT Token (1–15 minutes) is used to remove hard dependency on the authorization server. However, what happens if the client’s scope is altered? How does the server, such as Bob, recognize that the client’s scope is no longer valid? The resolution to these queries depends on how frequently client scopes are modified. Typically, a client scope remains unchanged for a server-to-service call. You might add new scopes, but removing a scope happens less often. When a new scope is introduced, the authorization server issues a new certificate and there is no need to revoke the old certification. If the client needs to use the new scope, they need to update their certificate. But again, what if a scope is removed?

Certification Revocation List (CRL)

The certification Revocation List is a list of revoked certificates by CA before their expiration time. It’s a simple list of revoked certifications. It is signed with the CA certificate and is reliable. In this case, the servers need to load the CRL in intervals, for example, every hour (depending on your use case), and validate the signature. The CRL is served by the authorization server and can be cached on CDN, so no hard dependency. Also, CRL vulnerabilities include cases where clients load outdated CRL versions from the cache, face DDoS attacks aimed at the CRL, or experience latency due to a long revocation list.

Cloud Signing Key Services

To make this process safer you can implement the Golang Signer interface by AWS KMS, GCP Cloud Key Management, or Vault Transit Engine. Signing the certificate with cloud providers ensures heightened security as no one has direct access to the private keys. However, this approach may incur additional costs (both financial and in terms of latency) for both the authorization server during certificate signing and the client during request signing.

Why Not issue a long-lived JWT token instead of a certificate?

A JWT token is considered a secret and storing it in the client is a security concern. But the certificate is not a secret and could be used publicly. In the certificate solution, the client only needs to protect their private key.

The JWT token is present in every request, which poses a security risk as it exposes clients to potential network vulnerabilities. But the certificate is not a secret and the client’s private key (which is also used to sign the request) never leaves the client.

Proof of concept

I developed a proof of concept in Golang for this solution to test and compare the benchmark and performance results between these two solutions. You can find the repository here on Github.

CLI

CLI tool is designed to generate the Primary CA certificate, client’s key pairs, client’s certificate, and JWT tokens. Start with the help parameter to explore the options. Almost everything is configurable.

After cloning the repository, you can build the binaries by running make build command.

For example, to generate a CA certificate and key pairs you can run this command:

./bin/cli create ca --name primary --path ./credentials --common-name "Primary CA" --organization "RedRad" --expiration 8766h --serial-number "123456789" --key-size 2048

To generate an example CA and clients run this command:

make generate-credentials

After running it, these directories are generated in credentials directory:

  • primary : It’s the master secrets directory. A key pair and CA certificate are generated in this directory. You can name it differently by generating it using cli tool.
  • alice : Alice is an example client. A key pair, an x509 certificate signed by primary ‘s private key and a JWT token are generated. In our examples, alice calls bob .
  • bob : Bob is also an example client with different scopes. Same as alice , A key pair, an x509 certificate signed by primary ‘s private key and a JWT token are generated.
/credentials
/primary
ca_certificate.crt
private.key
public.pub
/bob
certificate.crt
primary.key
public.pub
token
/alice
certificate.crt
primary.key
public.pub
token

HTTP Server

The Server package operates as an HTTP server, offering two routes each equipped with different middleware. One middleware is responsible for authenticating requests using a valid JWT token, while the other ensures request authorization through a valid client certificate.

You can run the server using this command:

make run-server

The server needs primary public key to validate a JWT token and the CA certificate to validate a certification. You just need to pass primary-name as a parameter while running the server. It will load the public key and CA certificate automatically. It also supports more parameters like running the server on a different host or port.

The server package runs an HTTP server listening on the port 8585. There are two routes:

  • /token : a valid JWT token in the Authorization header is expected. If you used make generate-credentials command to generate example clients, a file including the JWT token is generated in the client directory.
  • /cert : a valid base64-encoded client certificate in the X-Client-Cert is expected. if you used make generate-crendetials command to generate examples, you can find the certificate in the client’s directory. The client’s private key must sign the request. You can find out how to sign the request in the client implementation or in /scripts/hey_benchmark_cert.sh .

Client

Client package is an example Golang HTTP client. You can run this command to send an example HTTP request to the server with a JWT token:

make token-request

and run this command to send a request with a certificate:

make cert-request

Benchmark

It’s time to compare the performance of these two approaches. Benchmark tests exist in the validator package. Two keys with different sizes are used in the tests.

JWT Token Validation

$ make benchmark-test-jwt
go test -run none -bench . -benchmem ./core/jwt/...

goos: darwin
goarch: amd64
pkg: github.com/theredrad/certauthz/core/jwt
cpu: Intel(R) Core(TM) i5 CPU @ 2.00GHz
BenchmarkValidatorValidate2048KeySize-8 14466 82353 ns/op 9148 B/op 81 allocs/op
BenchmarkValidatorValidate4096KeySize-8 4648 250614 ns/op 19301 B/op 82 allocs/op

For a 2048-bit key, it takes about 0.082353ms per operation (on my machine) to validate a JWT token; and for a 4096-bit key, it takes 0.250614ms per operation to validate a JWT token. A 2048-bit key is secure enough for signing a JWT token these days (who knows when Quantum computers can break RSA keys, maybe in some seconds after now).

Certificate validation

$ make benchmark-test-cert
go test -run none -bench . -benchmem ./core/cert/...

goos: darwin
goarch: amd64
pkg: github.com/theredrad/certauthz/core/cert
cpu: Intel(R) Core(TM) i5-1038NG7 CPU @ 2.00GHz
BenchmarkValidatorValidate2048KeySizeWithDecodeBase64-8 12578 92977 ns/op 11974 B/op 83 allocs/op
BenchmarkValidatorValidate2048KeySizeWithDecode-8 12955 94241 ns/op 9797 B/op 81 allocs/op
BenchmarkValidatorValidate2048KeySize-8 14874 80256 ns/op 5194 B/op 19 allocs/op
BenchmarkValidatorValidate4096KeySizeWithDecodeBase64-8 4206 272157 ns/op 23057 B/op 84 allocs/op
BenchmarkValidatorValidate4096KeySizeWithDecode-8 4161 262399 ns/op 19600 B/op 82 allocs/op
BenchmarkValidatorValidate4096KeySize-8 4304 251071 ns/op 14741 B/op 20 allocs/op

There are more benchmark tests for certification validation. In this case, it’s not only the certificate validation process (like validating the signature and traversing the certificate chain), it also includes more operations like decoding the base64-encoded or decoding the certificate from DER format to bytes in Golang.

As you see, validating the certificate itself (with only CA in the chain) takes a similar amount to validating a JWT token for a 2048-bit key. But if we involve the base64 decode and DER decode parts, it takes a little bit more around 0.02ms more.

Again, this is the benchmark result on my machine and it varies in different machines with different resources. But it’s comparable if we run them in the same environment.

HTTP Load Test

We can not rely on the validation part only. For the JWT token, it would be OK, but there are more operations for certificate-based authentication.

As mentioned before, in certificate-based authentication, the client sends its base64-encoded certificate in the header and also signs the request. The client request’s signature validation operation also impacts the overhead.

You can find two scripts to generate loads on the HTTP server using hey in scripts directory. In this case, you just need to pass the client name and the server host to the script. It uses hey to generate loads and then calculates the average response time.

You can run these commands to generate loads:

make benchmark-server-token
make benchmark-server-cert

The average response time including percentiles depends on the server processing power and might differ per machine. However, the result would be comparable by running the load test multiple times in the same environment.

In short, certificate-based authentication doesn’t add much extra overhead for the server, but it does require a bit more effort from the client when preparing the request — calculate payload hash and sign the request — instead of calling the authorization server to obtain a JWT. While more complicated to set up and troubleshoot, it serves as a reliable backup for JWT tokens in cases where the authorization server is inaccessible.

Feel free to challenge this article, ask questions, or run the benchmark and load test and share your results.

--

--

Sajjad Rad

Currently a software engineer, always an adventurer