OAuth client authentication - more than just client secrets

Scott Brady
Scott Brady
OAuth

Client authentication allows an OAuth client application to prove its identity to an OAuth authorization server. The simplest way to do this is using a client secret, but client authentication is so much more than just client secrets.

In this article, you’ll learn about the various client authentication methods available to you in OAuth, both symmetric and asymmetric, and why you might want to move away from client secrets.

What is OAuth client authentication?

OAuth client authentication allows an OAuth client application (the application that wants to act on the user’s behalf) to verify their identity at various endpoints at the OAuth authorization server. By requiring authentication, you prevent applications from impersonating one another.

For example, suppose a client application wants to get a token from the authorization server’s token endpoint, and the authorization server wants to ensure only that application can get tokens. In that case, the client application provides its own set of credentials, verifying its identity and proving that it is the legitimate application, not someone impersonating it.

A client application asking an authorization server 'Hi, it’s the admin portal. I've signed the request with my certificate to verify my identity. Can I get some tokens, please?'.

Let’s look at a token request using the client credentials grant type. Here, the client application uses a client ID and a client secret to verify its identity. It’s the application using its own username and password, separate from any user credentials.

POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=a0897e6d0ea94f589c38278bca4e9342
&client_secret=c94dbd582d594e8aa04934f9c7ef0f52

Client authentication is not dependent on the grant type. It works for any grant type at the token endpoint.

When to use client authentication

It is best to use client authentication wherever possible. If the application can keep a secret, then it should authenticate itself with its own credentials. This makes it a “confidential client”.

Without client authentication, the client application becomes a “public client”, and the authorization server cannot trust the application to the same level. This might mean shorter access token lifetimes or no refresh tokens.

Client authentication is different than PKCE and solves a different problem. They work well together but do not replace one another. You must still use client authentication when using PKCE.

Let’s look at the client authentication methods available to you in OAuth.

OAuth client secrets

  • Name: client_secret_post or client_secret_basic
  • RFC 6749
  • Symmetric authentication
  • Not unique per request

The simplest way for a client application to authenticate itself is to use a client secret – its own username and password. A client secret is a shared secret known to both the client application and the authorization server.

POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=a0897e6d0ea94f589c38278bca4e9342
&client_secret=c94dbd582d594e8aa04934f9c7ef0f52

This is similar to an API key; however, instead of sending the API key on every request to an API, you are instead using the key to get an access token. This limits the exposure of the secret.

A client secret should not be human-readable; instead, it should be a random value generated by a machine. The authorization server should not store this value in plaintext; it only needs to know a hash of the value, just like it would with an end-user’s password. If you ensure that the client secrets are randomly generated and have enough entropy (e.g. 32 bytes), then you can get away with a single round of SHA-256 rather than a full-blown password hashing algorithm. Those kinds of values won’t be on anyone’s word list.

OAuth Basic Authentication

You can send a client secret in the body of the request using the client_id and client_secret parameters, or you can send it in the header using HTTP Basic authentication. The original OAuth standard (RFC 6749) recommends this over the request body.

POST /token HTTP/1.1
Host: auth.example.com
Authorization: Basic YTA4OTdlNmQwZWE5NGY1ODljMzgyNzhiY2E0ZTkzNDI6Yzk0ZGJkNTgyZDU5NGU4YWEwNDkzNGY5YzdlZjBmNTI=
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials

It’s worth noting that this is slightly different than the usual basic auth you might be used to. Using RFC 7617’s definition of basic authentication, you would expect:

[base64(client_id + : + client_secret)]

This definition goes as far back as the original HTTP 1.0 specification.

However, OAuth 2.0 defines basic authentication as:

[base64(form-urlencoded(client_id) + : + form-urlencoded(client_secret))]

It’s worth noting this subtle difference, as it can cause issues between OAuth implementations

Client secret JWTs

  • Name: client_secret_jwt
  • OpenID Connect
  • Symmetric authentication
  • Unique per request

A client secret JWT replaces the client secret in the token request for a JSON Web Token (JWT). Defined as part of OpenID Connect, this client authentication method uses a JWT with a specific payload, using the client secret as a symmetric key for the JWT signature.

POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=a0897e6d0ea94f589c38278bca4e9342
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhMDg5N2U2ZDBlYTk0ZjU4OWMzODI3OGJjYTRlOTM0MiIsImF1ZCI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLmNvbSIsInN1YiI6ImEwODk3ZTZkMGVhOTRmNTg5YzM4Mjc4YmNhNGU5MzQyIiwianRpIjoiZjY3Yjc2NGFkYjc4NGQ3Nzk5MjU0ZTYxMjhkY2FjNTIiLCJleHAiOjE2NjEyNjA3ODAsImlhdCI6MTY2MTI2MDQ4MH0.hAIDgyvS4d1eRq--K3vToBFG19lPC9bgJxNq93e2yqg

If you decode the token, it has the following header and payload:

{
  "alg": "HS256",
  "typ": "JWT"
}.
{
  "iss": "a0897e6d0ea94f589c38278bca4e9342",
  "aud": "https://auth.example.com",
  "sub": "a0897e6d0ea94f589c38278bca4e9342",
  "jti": "f67b764adb784d7799254e6128dcac52",
  "exp": 1661260780,
  "iat": 1661260480
}

These tokens follow the format defined in RFC 7523 (JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants). In this instance, the token needs to follow the rules for client authentication, where:

  • The issuer (iss) claim must be the client_id of the client application
  • The subject (sub) claim must be the client_id of the client application
  • The audience (aud) claim must be the authorization server
  • The JWT ID (jti) is unique to that token request (the token must not be re-used)
  • The token has an expires at (exp) claim, and, optionally, an issued at (iat) claim.

This client authentication method still uses shared secrets; both the client application and the authorization server must know the key used to sign the token (well, to create the MAC). However, this is an improvement on client secrets, as it removed the shared secret from the token request, further limiting the exposure of the secret. It also prevents the replay of token requests, requiring a new credential each time.

Using a client secret JWT still requires a strong client secret. An attacker can steal a token and start brute-forcing the HMAC. Remember to follow best practices to make this unfeasible.

Private key JWTs

  • Method: private_key_jwt
  • OpenID Connect
  • Asymmetric authentication
  • Unique per request

A private key JWT again replaces the client secret in the token request for a JWT; however, this time, you sign the JWT using asymmetric cryptography. This completely removes the use of shared secrets, instead signing the token using a private key only the client application knows and validating it using a public key that the authorization server knows. This method is again defined as part of OpenID Connect.

POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=a0897e6d0ea94f589c38278bca4e9342
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IjJkNGQ0NDY1ODQ5ZmZkOTI0ZTUzMWNjYzZmNTI4ZDJkIn0.eyJpc3MiOiJhMDg5N2U2ZDBlYTk0ZjU4OWMzODI3OGJjYTRlOTM0MiIsImF1ZCI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLmNvbSIsInN1YiI6ImEwODk3ZTZkMGVhOTRmNTg5YzM4Mjc4YmNhNGU5MzQyIiwianRpIjoiZjY3Yjc2NGFkYjc4NGQ3Nzk5MjU0ZTYxMjhkY2FjNTIiLCJleHAiOjE2NjEyNjA3ODAsImlhdCI6MTY2MTI2MDQ4MH0.UarwkYL_zLyx-QeGKDu8WXeWkOXSdvfWryvKfHsx8w6SL2ztyikAUalmNiBbHva3ckq-PSETTGowrIXwXbLBOQ

If you decode the token, it has the following header and payload:

{
  "typ": "JWT",
  "alg": "ES256",
  "kid": "2d4d4465849ffd924e531ccc6f528d2d"
}.
{
  "iss": "a0897e6d0ea94f589c38278bca4e9342",
  "aud": "https://auth.example.com",
  "sub": "a0897e6d0ea94f589c38278bca4e9342",
  "jti": "f67b764adb784d7799254e6128dcac52",
  "exp": 1661260780,
  "iat": 1661260480
}

These tokens follow the format defined in RFC 7523 (JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants). In this instance, the token needs to follow the rules for client authentication, where:

  • The issuer (iss) claim must be the client_id of the client application
  • The subject (sub) claim must be the client_id of the client application
  • The audience (aud) claim must be the authorization server
  • The JWT ID (jti) is unique to that token request (the token must not be re-used)
  • The token has an expires at (exp) claim, and, optionally, an issued at (iat) claim.

In the event of a database breach at the authorization server, the attacker will not be able to steal client credentials, as they will only have the client application’s public key, which is useless on its own. While client credentials are likely not your biggest concern in the event of an authorization server breach, it is at least one less thing to worry about.

Mutual TLS (mTLS)

  • Name: tls_client_auth or self_signed_tls_client_auth
  • RFC 8705
  • Asymmetric authentication
  • Not unique per request

mTLS as a client authentication mechanism allows the client application to authenticate itself to the authorization server using client certificate authentication. This could be using a certificate signed by a trusted Certificate Authority (CA) or a self-signed certificate.

A client application telling an authorization server 'Hi, it’s the admin portal. I’ve signed the request with my certificate to verify my identity. Can I get some tokens, please?'.

mTLS isn’t the best mechanism for authentication, and it operates at the connection level rather than individual requests like the previous JWT-based mechanisms (which is why I cannot show it in action on an HTTP request like the other examples). Also, it only really works for server-side client applications; otherwise, the user experience falls apart.

However, the real benefit of this client authentication mechanism is that it can offer a form of proof of possession. You can bind the resulting access token to that client certificate. This means you can only use the access token at an API on a connection using that same client certificate.

{
  "typ": "JWT",
  "alg": "ES256",
  "kid": "2d4d4465849ffd924e531ccc6f528d2d"
}.
{
  "iss": "a0897e6d0ea94f589c38278bca4e9342",
  "aud": "https://auth.example.com",
  "sub": "a0897e6d0ea94f589c38278bca4e9342",
  "jti": "f67b764adb784d7799254e6128dcac52",
  "exp": 1661260780,
  "iat": 1661260480,
  "cnf": {
	"x5t#S256": "AlpHni_Zu2C4GpEoQYG7mH1FvZ4xMQRAhlPzSIQhdfg"
  }
}

This is a topic for another day, but in the meantime, I recommend reading Neil Madden’s blog post on the subject to learn the shortcomings of mTLS as an authentication mechanism and how it works better as a proof of possession mechanism.

While it’s officially disallowed in the OAuth spec, I can’t see why you couldn’t combine mTLS with other client authentication mechanisms, gaining the benefits of certificate-bound access tokens while mitigating the security limitations of mTLS. But at that point, DPoP would be much simpler.

None

  • Name: none
  • RFC 6749
  • No authentication
  • Not unique per request

The final option is to simply have no client authentication at all. This is often the case with a client application that cannot keep a secret, such as a Single Page Application (SPA, code running in the end-user’s browser) or a mobile application. In the OAuth world, these are known as public clients, where the thinking is: “they cannot keep a secret, so why bother?”.

However, some argue that giving credentials to a public client does add an extra layer of security, an extra hurdle for the attacker to overcome. Personally, I’m not so sure. My main worry is that misconfiguration at the authorization server can make it consider the client application a confidential client and give it more trust than it deserves. My other concern is that while you may see it as just an extra hurdle now, future rearchitectures and redesigns may accidentally give it more worth than it deserves.

I’ve seen this happen a few too many times to ignore. One example that comes to mind is a mobile app passing around a tenant key so that the API gateway could understand the current tenant. However, since they called this key an “API key”, both internally and in the HTTP request, everyone started treating it like a secret key. After some employee turnover and changes in company direction, this tenant key suddenly became one of the main security controls. The same key they embedded in every installation of the mobile app.

Bonus: Authorization endpoint authentication using JAR

So far, every client authentication technique has been for the token endpoint; but there is a method for gaining some level of authentication at the authorization endpoint using the JWT-secured Authorization Request (JAR) defined in RFC 9101.

By default, authorization requests pass via the browser and are therefore unsecured and open to tampering. This is usually fine if both the client application and the authorization server are doing their thing correctly, there’s not too much that can go wrong. However, if you want to prevent anyone from tampering with the authorization request and also to authenticate the requesting application, you can secure the request by again sending a JWT.

{
  "typ": "JWT",
  "alg": "ES256",
  "kid": "a47b207726893a2208999f0b9d6a16f4"
}.
{
 "iss": "a0897e6d0ea94f589c38278bca4e9342",
 "aud": "https://auth.example.com",
 "response_type": "code",
 "client_id": "a0897e6d0ea94f589c38278bca4e9342",
 "redirect_uri": "https://client.example.org/callback",
 "scope": "openid",
 "state": "809600d433284a4f800ab892eebb312e",
 "nonce": "0c2bbe474855408a8cd4e6d7a5ab7dec"
}

This JWT is signed and optionally encrypted, allowing the authorization server to validate the integrity of the authorization request and authenticate the request application. Ideally, this should use asymmetric cryptography.

GET /authz?client_id=a0897e6d0ea94f589c38278bca4e9342&request=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6ImE0N2IyMDc3MjY4OTNhMjIwODk5OWYwYjlkNmExNmY0In0.eyJpc3MiOiJhMDg5N2U2ZDBlYTk0ZjU4OWMzODI3OGJjYTRlOTM0MiIsImF1ZCI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLmNvbSIsInJlc3BvbnNlX3R5cGUiOiJjb2RlIiwiY2xpZW50X2lkIjoiYTA4OTdlNmQwZWE5NGY1ODljMzgyNzhiY2E0ZTkzNDIiLCJyZWRpcmVjdF91cmkiOiJodHRwczovL2NsaWVudC5leGFtcGxlLm9yZy9jYWxsYmFjayIsInNjb3BlIjoib3BlbmlkIiwic3RhdGUiOiJhZjBpZmpzbGRraiIsIm5vbmNlIjoibi0wUzZfV3pBMk1qIn0.xj1dOEw4AIiuNYRBGdJCuwsa9RKKok71yNxoWAn_1Ox1vVWe0hRQf1EAWC-JD2D_EreiDY0zepnuawP1fmldVQ HTTP/1.1
Host: auth.example.com

You can then send this JWT to the authorization server in place of the authorization request parameters it is protecting. There is a method to pass a reference to the JWT, but I prefer stuffing it in the URL if query string length limitations allow.

Summary

Public-key crypto Unique per request
Client secrets
Client secret JWT
Private key JWT
mTLS

In general, asymmetric credentials will always be better than a symmetric alternative. Ignoring proof of possession, for now, I prefer the private key JWT approach over mTLS since it is much simpler and doesn’t suffer from the security limitations of mTLS. For proof of possession, I’m holding out hope for the adoption of DPoP.

The IANA OAuth parameters registry does have a section for token endpoint authentication methods, including their values for metadata documents. It’s worth monitoring this and the OAuth working group for new values.