Introduction to OAuth Security

Introduction to OAuth Security

OAuth 2.0 and OpenID Connect are similar protocols commonly used for identity and access management. The purpose of this blog is to provide an in-depth description of the OAuth 2.0 protocol and discuss various security controls required to protect against OAuth-specific attacks which can help you defend and test your applications.

Applications often need to manage resources on behalf of a user. These resources are often owned by a third party, for example, a productivity application might need access to a user’s emails, but the email account is owned by Microsoft. In the early days of the internet, the only way this access could be granted was for the user to directly provide the productivity application with their Microsoft username and password. This was very problematic from a security perspective, because:

  • Applications gained complete access to user accounts, including access to all functionality whether it was required or not.
  • Applications needed to store user credentials in a recoverable format, often in plain text.
  • Access granted to applications could not be easily revoked.

OAuth 2.0 is an authorization protocol designed to solve all of these issues. Instead of requiring the user to share their credentials, OAuth allows the third party to issue an access_token on behalf of the user. This access token is usually a Bearer JSON Web Token (JWT) with signed claims representing the duration and level of access granted to the application.

OpenID Connect (OIDC) is an authentication protocol designed for an application to allow a trusted identity provider (IdP), such as Google or Microsoft, to identify a user. OIDC is considered to be built on top of OAuth 2.0, as it uses the OAuth flow to “share” the user’s identity in the form of an id_token.

Although both protocols have become commonplace, their exact implementation differs across providers. The Request for Comments (RFCs) provide skeletal definitions of each protocol, and allow plenty of room for extension. The OAuth protocol also has a history of revisions, mostly due to unique security issues continuously being discovered in the protocol itself.

The following references can be used to read more:

Understanding OAuth 2.0 can be beneficial for anyone involved in web application security. OAuth misconfigurations remain a major security issue in 2023, as demonstrated by Franz Rosen’s 2022 research into postMessages as an OAuth attack vector, and Salt Labs’ recent consecutive discoveries of CVE-2023-28131 and Account takeover on Booking.com.

Let’s continue with a description of the OAuth 2.0 protocol and the various security controls required to protect against OAuth-specific attacks.

Roles and Definitions

The OAuth protocol defines four roles: the end user (or Resource Owner), clientauthorization server, and resource server. The high-level interactions between these roles are shown in the image below.

Clients

The application that receives the access token is called the client. The term can be misleading as it is unrelated to the common usage of the word, which is synonymous with “front-end”. This can make the idea of a “confidential client” confusing, as a confidential client requires the ability to make server-side requests.

A client should be identified by a unique client_id, and optionally a client_secret. There are two types of clients, distinguished by their ability to securely maintain their client secret.

  • Public clients do not maintain a secret and therefore cannot securely identify themselves to the authorization server. Most native applications and “single-page” web applications are public clients, as as all user data is stored within the applications’ front-end.
  • Confidential clients are capable of securely maintaining a client secret, for example, by storing the secret on the application back-end. They can then identify themselves to the authorization server by including the client_secret in a back-end request. Traditional web applications where logic is performed server-side can be confidential clients.

Authorization server (AS)

The authorization server is responsible for identifying the end user and issuing the required tokens for resource access. The authorization server and resource server often belong to the same business entity.

Often, the authorization server will also perform client authentication. This is necessary when an authorization server is shared by many applications (e.g. internal vs third party) that also vary in degrees of trust granted. For example, Google distinguishes between verified and unverified third-party applications, and allow verified applications to access more user data. The verified applications must therefore authenticate with the authorization server, independent of the user whose privileges it wishes to assume.

The difference between public and confidential clients is significant when client authentication is required. As a rule, high-privileged access should only be granted to confidential clients due to the risk of client impersonation.

The following two endpoints must be offered by an authorization server:

  • The authorize endpoint e.g. myapp.com/auth, or myapp.com/signin.
  • The token endpoint e.g. myapp.com/token.

The exact purpose of these endpoints requires more context around the information exchanged between the client and authorization server, this will be discussed further in Flows.

An OIDC provider must also host a configuration endpoint at /.well-known/openid-configuration. For example, Google’s OpenID configuration can be found at https://accounts.google.com/.well-known/openid-configuration. This can help during the information gathering phase, as custom extensions must be listed.

Resource server (RS)

The resource server hosts the user resources required by the client. This could be an API that returns private data belonging to the end user. The resource server should be able to verify access tokens issued by the authorization server, usually through a shared secret.

User-Agent

The user-agent is the way the end user communicates with the other services. This is usually assumed to be a web browser, as the OAuth protocol was designed to use existing browser features.

Abstractly, the user-agent needs the ability to:

  • Store data (e.g. in cookies).
  • Make cross-origin redirects.

Although the role of the user-agent is not defined in the OAuth specification, it is important when considering concrete implementations of the protocol. This is particularly true for native applications, where the user-agent can also be the device. The OAuth RFC is lacking in the mobile-specific recommendations. These can instead be found in the best current practice, which advises, among other things, that authorization requests from native applications should only be made through external user-agents.

Tokens

Access tokens

As previously discussed, the access token is a type of credential that is issued by the authorization server to the client, which then uses it to authorize with the resource server. The access token should explicitly declare its own scope and duration of access. Almost always, the access token will be a JSON web token such as below (example from Auth0).

{
  "iss": "https://my-domain.auth0.com/",
  "sub": "auth0|123456",
  "aud": [
    "https://example.com/health-api",
    "https://my-domain.auth0.com/userinfo"
  ],
  "azp": "my_client_id",
  "exp": 1311281970,
  "iat": 1311280970,
  "scope": "openid profile read:patients read:admin"
}

The JWT must contain the following claims:

  • The iss (issuer) claim identifies the authorization server issuing the token.
  • The sub (subject) claim uniquely identifies the end user as an entity within the authorization server (Note: not the client).
  • The aud (audience) claim identifies the intended audience for the token, and must include the authorization server.
  • The exp (expiration time) claim declares the time at which the token expires, used to limit the token’s duration of access.

Refresh tokens

An authorization server often issues a refresh_token alongside the access token. This is usually a JWT, but custom formats are not uncommon. The main purpose of the refresh token is for the user to request more access tokens from the authorization server without needing to re-authenticate, allowing for long-term access.

Refresh tokens should only be stored within the client, and only be sent to the authorization server, never to the resource server. Some security considerations include:

  • If the refresh token should persist across sessions, it must be stored in a secure location e.g. within a device’s native keychain.
  • A new refresh token should be issued once the previous token has been consumed, limiting its ability to be replayed by an attacker if compromised.

If implemented correctly, using refresh tokens is preferable to using a long-term access token, which is more likely to be logged in transit for example.

Flows

There are four authorization flows described in the OAuth 2.0 RFC, two of which are not required for this article. The two of concern here are the authorization code flow and the implicit flow, where the authorization code flow is the most commonly used.

The implicit flow is rarely seen in web-facing applications, as it is considered very difficult to implement securely, if not fundamentally insecure. However, it is not only supported by almost all authorization servers, but often active by default, even if it is never used by any clients.

OIDC also defines a hybrid flow, which is a combination of the two. Although this is rarely used by clients, it is sometimes supported on the IdP side by default.

Both the authorization code and implicit flows begin the same way:

  • Firstly, the authorization request sends the user from the client application to the authorization server, usually through a HTTP redirect.
  • After the user authenticates with the authorization server, the authorization grant is issued in response and returns the user back to the client application, again typically through a HTTP redirect.
    • In the authorization code flow, the authorization grant contains a one-time code that can be used in a third step to exchange for the access token.
    • In the implicit flow, the authorization grant will contain the access_token directly.

The third token exchange step is used only in the authorization code flow and uses the /token endpoint on the authorization server. If the client is a confidential client, this step can be performed server-side, i.e. independent of the user-agent. This is the only scenario where the flow is not entirely visible to the user-agent.

The key differences between the OAuth flows are summarised in this table:

Flow Code (Public) Code (Confidential) Implicit
Authorization grant contains token No No Yes
Authorization grant contains code Yes Yes No
/token endpoint used Yes Yes No
Token visible to user-agent Yes Optional Yes
Client can be authenticated No Yes No

Authorization request

The authorization request is initiated from the client and lands on the authorization server as shown in the following flow:

The following (compulsory) parameters are then used to provide the authorization server with the information needed to initiate a login session:

  • The response_type parameter decides the flow that should be used. The options include:
    • code
    • token
    • id_token

Note: response_type=code requests a code to be returned for the authorization code flow, whereas response_type=token and response_type=id_token request a token to be returned in the authorization grant, characteristic of the implicit flow. A combination of the two, such as response_type=code id_token is sometimes also allowed, and corresponds to the hybrid flow.

  • The client_id should be a unique string used to identify the client. The authorization server requires this field to perform client-specific authorization checks, such as performing validation on the redirect_uri. It is also required for client authentication for confidential clients.
  • The redirect_uri specifies the callback location on the client application. Valid redirect URIs must be pre-registered in the authorization server, and the supplied value must exactly match a pre-configured option. For example, my application could register the following callback URIs:
    • https://myapp.com/callback
    • https://myapp.com/callback/oidcSome OAuth and OIDC services permit matching on the hostname only, or allow the use of wildcards and regular expressions, for example:
    • https://myapp.com
    • https://myapp.com/callback/*This is not advisable, and is prohibited by the OIDC specification (section 3.2.2.1). This is because the entire OAuth flow would then act as an open redirector, and an attacker could manipulate a flow so that the secrets land on a page under the attacker’s control, such as one with XSS or an open redirect.
  • The scope parameter should specify a list of strings that represent the access scope requested by the application. For example, an application that allows users to make purchases could implement this using scope=["purchases"]. In an OIDC flow, this parameter must contain the value openid.If the scope parameter is to be used by the authorization server, the authorization server must also ensure that the user has permissions to access the scopes requested.

The following parameters provide protections against attacks on the OAuth flow (see section OAuth security considerations for more details):

  • state: a random string generated by the client, used to protect against cross-site request forgery (CSRF).
  • nonce: similar to state, but returned as a signed claim within the access token.
  • code_challenge: used for Proof Key for Code Exchange (PKCE) by OAuth public clients, and used to protect against code interception attacks. If a code challenge is used, the code_challenge_method should also be declared, and can either be S256 or plain.

Authorization grant

The authorization grant issues the required credentials from the authorization server as summarised here:

The placement of these credentials is important between the authorization code and implicit flow:

  • In the authorization code flow, the code must be delivered in a query parameter so that the application back-end can use it in a token exchange request.
  • In the implicit flow, access tokens must be delivered in the URI fragment. URI fragments are not sent to the application backend, and prevent the token from being logged by intermediary services.
Flow Credential placement Correct
Code GET client/callback?code=xxx... Yes
Code GET client/callback#code=xxx... No
Implicit GET client/callback?access_token=eyJ... No
Implicit GET client/callback#access_token=eyJ... Yes
Hybrid GET client/callback?code=xxx...&token=eyJ... No
Hybrid GET client/callback?code=xxx...#token=eyJ... Yes
Hybrid GET client/callback#code=xxx...&token=eyJ... No

Although placing the access token in the URI fragment prevents it from being logged by intermediary services, it is visible to JavaScript running within the browser e.g. browser plugins. This is one of the reasons the implicit flow is considered fundamentally insecure.

Token Exchange

The token exchange step is the last step of the authorization code flow, used to exchange the code for an access token:

  • If the application is a confidential client, this step must also include the client_secret, and therefore must be a server-side request.
  • If the application is a public client, PKCE should be used, and therefore the code_verifier must also be included in the token exchange step.

The code returned in the authorization code flow should expire after a single use, i.e. the token exchange request should not be replayable.

OAuth security considerations

The OAuth RFC contains a large section on security considerations, and for good reason. Since OAuth/OIDC is often the “front door” of an application, any flaws in the protocol would have serious impacts on user account security. Many of these issues are minor weaknesses by themselves, but can greatly increase the impact of other issues, such as cross-site scripting (XSS).

Client impersonation

Client impersonation can occur when a confidential client exposes its client_secret, allowing a malicious application to authenticate with the authorization server as the high-privileged client. The application could then present a legitimate OAuth login flow to its users, using the compromised client_secret to authenticate with the backend and allowing it to request access to more sensitive user data.

Redirect URI manipulation

The redirect_uri supplied in the authorization request should ideally be a static, pre-configured value. However, this is not enforced by the specification, and it is not uncommon to see host-based URL matching. In 2013, Egor Homakov called this the Achilles heel of OAuth, after divulging a series of implementation flaws in Facebook’s OAuth service.

For example, if partial redirect_uri matching is performed, an attacker could use the redirect_uri to redirect the authorization grant (containing user secrets) to an arbitrary location on the client application. If stored XSS is found anywhere within the client (e.g. in myapp.com/page-with-xss), the secrets returned within the authorization grant could then be compromised via a 1-click phishing link, such as:

Malicious link:

https://auth.myapp.com?response_type=token&client_id=xxx&redirect_uri=https://myapp.com/page-with-xss

Authorization grant:

GET /page-with-xss#access_token=eyJ...

This also provides a method to circumvent browser protections that would otherwise prevent cross-site scripts from accessing user secrets, as the URL will be accessible to JavaScript running within the page.

Cross-Site Request Forgery (CSRF)

Cross-site request forgery is a common weakness in which authenticated users can be manipulated into submitting unwanted actions within an application, for example, submitting GET request parameters via a malicious phishing link. OAuth requests are especially susceptible to CSRF attacks, as the entire flow is typically driven by URL redirects.

In 2012, Igor Homakov describes how a 1-click phishing link can be created to cause a targeted user to enable an attacker’s account as an option for single-sign on (SSO), allowing the attacker to log in to the target’s account.

Applications that used the implicit flow were particularly susceptible to CSRF attacks, as users could be phished into using an application as an attacker-controlled account. Any sensitive data submitted to the application would then be immediately accessible by the attacker.

State

The state parameter was designed to prevent CSRF attacks by allowing an authorization grant to be associated with a user-agent. The state value must be a random value that is generated dynamically when a user initiates the authorization flow. This value should be stored on the user-agent before the user is redirected to the authorization server. When the user returns with the authorization grant, the state value contained within the grant should be compared against the value stored within the user-agent. If there is a mismatch, an error should be returned by the client.

The state value only protects against CSRF if it is generated uniquely for each user-agent session. Applications that use a fixed or insufficiently random state value are all vulnerable to CSRF attacks.

Authorization code interception

Recall that authorization codes are returned to the client application within a URL query parameter, so that confidential clients can use the code in a server-side request. However, this causes public clients to be susceptible to authorization code interception, where an attacker eavesdropping on the OAuth dance may be able to steal the access code, which can then be exchanged for an access token without client authentication.

Proof Key of Code Exchange (PKCE)

PKCE (pronounced “pixie”) is used to protect against authorization code interception by allowing a token exchange request to be associated with an authorization request. PKCE uses two values:

  • The code_verifier which should be a randomly generated string.
  • The code_challenge which should be the SHA256 hash of the code_verifier.

The code_challenge should be provided to the authorization server within the authorization request, allowing the authorization server to associate its value with the code issued. The code_verifier should then be included within the token exchange request alongside the access code, and the authorization server should verify its hashed value against the code’s associated code_challenge.

An attacker eavesdropping on the OAuth dance might be able to see the code_challenge and code, but never the code_verfier.

Conclusion

OAuth is everywhere and provides a great convenience. However, this convenience requires a level of complexity within its protocols and implementations. From a security point of view, this complexity is also a weakness, as small and often unassuming misconfigurations can result in serious security consequences. We hope that this article has helped you gain a deeper understanding of the OAuth protocol and its most common configurations that we see in deployment. The security issues and controls described here should be kept in mind when protecting and testing your applications.