API keys, also known as static API tokens, have long been used to call external APIs and access the resources of third parties such as software vendors. As API standards have evolved, OAuth 2.0 emerged as the option offering more robust security, greater flexibility, and a better developer experience. Let’s explore the advantages of moving away from static tokens in favor of OAuth 2.0.
API Key Landscape
In a secure software system, all calls that allow read or modify access to resources must be authorized. When you call a protected API, the API needs some way to validate authorization. API keys are a way to provide authorization information to the API using a remembered value without user context. As a result, they provided a straightforward (but limited) approach for developers to grant API access to another app: generate a key, store it once, and proceed with the task.
If we look at an example HTTP call to a product API, a request with an API key may look something like this:
GET /api/products HTTP/1.1 Authorization: KEY <api_key_value> Accept: application/json Host: myapiserver.com
The caller of the API (aka the client) adds the key value to the call. Depending on the vendor system called, the API may add the API key value to an HTTP header, such as
Authorization (using a standard authorization scheme or a proprietary one such as the
KEY scheme in the example), or in a query parameter.
From API Keys to OAuth 2.0 for Better Security
As with many technologies, this ease of use comes with a corresponding increase in exposure to risk. Unlike short-lived OAuth 2.0 access tokens, API keys typically have a long life. API keys set in query parameters are particularly dangerous. Browsers save URLs in history, and logging systems may record the entire URL, exposing the key more readily than when using HTTP headers.
To better understand the security concerns associated with API keys, let’s dive into some of their characteristics in detail, and compare and contrast with OAuth 2.0 as an alternative.
Access risks when using API keys
API keys can fall into the wrong hands, leading to unauthorized access. For example, API keys can inadvertently get checked into source control systems, exposing the key inadvertently to repository viewers and the source control history. Since API keys are long-lived, the risk of exposing them is particularly concerning as the outcome of a stolen key is impactful — a stolen API key remains active until manually inactivated or when it finally expires, allowing unauthorized access.
API key rotation troubles
Rotating API keys can be a challenging task. Because API key values persist within the caller’s environment or software systems, timing the rotation to quickly release a key, generate a new one, and save the key while keeping production environments running can pose challenges.
Limiting scope is difficult
Assigning limited scopes to API keys isn’t straightforward. API keys usually grant access to the API called in its entirety — an all-or-nothing access level. Defining access for a limited scope, such as read access only vs. write access, and other fine-grained access control measures may not be available or difficult to manage through API keys.
Inherited permissions in user-generated key
User-generated API keys usually adopt the user account permissions that generated them. If an admin generates the key, it has full access to all the resources and actions the admin can perform. To alleviate this concern, sometimes organizations set up service accounts with limited scopes to generate the key. However, doing so leads to user management overhead and potentially taking a seat within a license.
It’s tricky to trace API key utilization. A core need for auditing is tracing a user’s interaction with an API. However, API keys usually don’t have user context, and all calls to the API use the same key, so identifying a specific user’s action with an API may be impossible.
API keys are brittle
API keys linked to user accounts become invalid when the user exits the organization. Some organizations use service accounts to counteract this measure, which opens up access risks and management overhead.
There’s a better, more secure way to access APIs that mitigate the risks associated with API keys: using industry-standard OAuth 2.0 flows.
Embrace OAuth 2.0 to Improve Security
OAuth 2.0 caters to almost any scenario in place of API keys. But unlike API keys, the OAuth specs include definitions and secure practices for necessities such as token rotation, basic authorization decisions, and more for everyday use cases like:
- Automated service-to-service interaction for handling behind-the-scenes API calls without user context,
- Task-based automation through your command line operations,
- Limiting application data and actions to the user’s access levels to retain authorization security.
OAuth 2.0 supports each of the everyday use cases listed above through configuration properties sent in a request to a centralized authorization server. The general interaction is a multi-step process like this:
- The caller requests an access token from the authorization server, passing in the required configuration properties for the use case. The authorization server returns a short-lived access token. The length of life for the token depends on the use case. Some use cases are inherently more secure, so the length of life can vary. The HTTP request below shows an example request to retrieve a token but doesn’t include the required configuration properties. You’ll see the necessary configuration properties in the upcoming examples:
POST /token HTTP/1.1 Host: authorization-server.com <plus required configuration properties + credentials>
- The caller uses the token value in the API request as a value in the
Authorizationheader of the HTTP request using the
Bearerscheme. The HTTP request may look something like this:
GET /api/products HTTP/1.1 Authorization: Bearer <api_token_value> Accept: application/json Host: myapiserver.com
There’s a lot to OAuth 2.0, so check out the resources at the end of this post for a deeper dive. We won’t dive into all the details in this post but will tailor the information specific to the use cases mentioned and provide a high-level summary for each.
Let’s look at each use case to see how OAuth 2.0 supports it.
You may have backend processes that call other APIs to get data or perform actions. OAuth 2.0 supports service-to-service or machine-to-machine requests without a user context associated with the call with an authorization flow called Client Credentials.
Advantages of using client credentials over API keys
Using OAuth 2.0 for service-to-service interaction has huge advantages over API keys, especially regarding access levels called scopes. In OAuth 2.0, you identify the scopes your access token gets as part of the configuration properties in a call to retrieve your token. You’ll want to ensure the API you’re calling supports scope-based access and that you’ve granted access to the scopes you need. For example, the Auth0 Management API has defined multiple scopes for resource access, such as
update:users. You can grant access to one or both of those scopes.
Client credentials flow is not the same as using service accounts for the API authorization. OAuth 2.0 supports machine-to-machine interaction in a standard format without taking up a user seat to emulate an automation user. Notice when you define scopes, you’ll do so in the configuration of the service application and within the authorization server, not at a user level. The advantage is you control resource access for the entire service application, not via a user within the application. You no longer have concerns about managing service accounts and can have confidence that the application cannot access more scopes than you’ve allowed.
Retrieve an access token using client credentials
To retrieve an access token for this flow, you’ll send properties specifying the type of authorization flow, the credentials, and the scopes the access token has. The OAuth 2.0 term for defining the authorization flow is called a
grant_type; for the client credentials flow, you’ll pass in the value
The credentials can either be a cryptographically secure JSON Web Token (JWT) signed with the client’s private key or a secret value generated from your authorization server. A private key JWT is more secure, as you won’t risk exposing the secret value that accidentally creates similar access concerns as an API key. You’ll generate a public/private key pair and register the public key in a JSON Web Key Set (JWKS) or upload it to the authorization server so that the authorization server has the public key to validate the JWT signature. Read more about this process in Authenticate with Private Key JWT documentation.
An example HTTP call to retrieve the access token may look like this:
POST /token HTTP/1.1 Content-Type: application/x-www-form-urlencoded Accept: application/json Host: authorization-server.com grant_type=client_credentials &scope=read:users &client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer &client_assertion=<generated_jwt>
Once you get the access token, you’ll add it to the API calls as a header:
Authorization: Bearer access_token.
Command line terminals, like Bash and PowerShell, are browserless processes to run individual commands or scripts within your terminal window. Unlike the previous service-to-service interaction, this flow involves user context. You want to run the command line operations with your authorization context, which means you’ll only have access to the API resources you’re supposed to have. OAuth 2.0 supports this use case with a flow called Device Flow.
Obtaining access tokens is a multi-step process with this flow. Before requesting the access token directly, like the service-to-service use case, you first obtain unique verification codes by sending the client ID to a special endpoint on your authorization server:
POST /device/authorize HTTP/1.1 Content-Type: application/x-www-form-urlencoded Host: authorization-server.com client_id=<client_ID>
The response from the
/device/authorize call returns two unique codes — a device code and a user code — and a verification URI. Confirm the authorization request by navigating to the verification URI in your browser and entering the user code.
To retrieve the access token, you add the
grant_type property to specify the OAuth 2.0 flow for the use case and the device code as configuration properties in the call:
POST /token HTTP/1.1 Content-Type: application/x-www-form-urlencoded Host: authorization-server.com client_id=<client_ID> &grant_type=urn:ietf:params:oauth:grant-type:device_code &device_code=device_code
Use the access token from the response in the
Authorization: Bearer access_token header.
User access through a web application
The last use case ensures the user’s authorization applies to API access when using a web application. OAuth 2.0 supports limiting an application’s actions to a subset of what the user can do and the data they see. In this flow, you have the user context to apply for resource authorization, plus having the user context makes auditing and tracing calls by individual users more straightforward. The OAuth 2.0 flow for this use case combines a standard flow, Authorization Code flow, and an extension for an extra layer of security called Proof Key for Code Exchange (PKCE).
Authorization Code flow with PKCE requires multiple steps to retrieve an access token and involves user interaction through consent and verification, which allows multi-factor authentication. In this flow, you’ll redirect the user to an authorization server for the authorization code before requesting an access token.
The redirect URL includes request-specific properties so they can complete signing in, including specifying the type of request using a
response_type property and the client ID, among other properties.
When the user successfully finishes signing in, the identity provider redirects back to the application with an authorization code in the authorization response. To retrieve the access token, you pass in the grant type for this flow, the authorization code, and other request-specific metadata.
POST /token HTTP/1.1 Host: authorization-server.com grant_type=code &code=<code_response> &client_id=<client_ID>
With the access token in hand, the web app adds the access token to outgoing API calls in the
Authorization: Bearer access_token header.
Read more about the authorization code flow in Authorization Code Flow with Proof Key for Code Exchange (PKCE).
Phase out API Keys and Use OAuth 2.0
Software migrations take time and planning, and having a good game plan helps. Consider transitioning to OAuth 2.0 in small, incremental steps.
The first step in migrating from API keys is ensuring the APIs you use support OAuth 2.0. Check with your vendor if you’re calling third-party APIs. If your API is internal, you’ll want to add OAuth support. Once you verify the APIs you’re calling support OAuth, you’ll need the client ID for the OAuth application and enable access measures such as scopes.
Let’s focus on the use case of an automated service-to-service call. Incorporating OAuth 2.0 within your APIs all in one shot is daunting, so let’s examine how to achieve this incrementally. Before making any code changes, you can try the steps locally by hand and use your favorite HTTP client to request a token and call an API using OAuth 2.0. This case uses Client Credentials flow with a private key JWT. The steps are:
- Generate the public/private key pair
- Use the public/private key pair to generate the private key JWT
- Request the access token
- Add the access token to your API request
- Incorporate OAuth into your application system
1. Generate the public/private key pair
You can generate a public/private key pair following these instructions.
2. Configure private key JWT authentication
Use the Auth0 dashboard or the Auth0 Management API to configure private key JWT authentication for your application. Follow the instructions in this document to learn how to configure your existing application or a new one.
Note that although you are configuring private key JWT authentication, you must upload your public key to Auth0. You must never share your private key, not even with Auth0!
Now, create your client assertion by following these instructions. The client assertion is a JWT signed by the private key that identifies your client.
3. Request the access token
Request the access token using your JWT assertion without adding any scopes by making the HTTP request in your favorite HTTP client. You should get an error when using the access token without the proper scope, but testing for errors and happy paths is good. The access token expires at some point, and you’ll see the expiration in the access token response. The access token is part of the response. You’ll use the entire string as the access token.
4. Add the access token to your API request
Using your HTTP client, call your API using the access token. You’ll add an
Authorization header and use the
Bearer scheme, so your API call has the following header matching the format shown below:
Authorization: Bearer <access_token>
When you make the API call, you should get back an HTTP status 403 error. Yay! Success? Well, yes, a successful error case test. Now you know what error to expect if there’s an error in configuring scopes. Let’s add the scopes to the access token request and try this again.
This time, request your access token using the JWT assertion and add the scopes for the API call. Use the access token in the API call. The moment of truth! You should see a successful response. You know there’s a configuration error if you get a 403 error again. If this happens, double-check the request scopes and that the OAuth 2.0 application has the scopes you’re requesting enabled.
5. Add OAuth 2.0 authorization within your system
Now that you’ve successfully stepped through OAuth 2.0 authorization for a server-to-service call manually, you can bring aspects of these steps into your system. In a development or test environment, try using the access token. You’ll want to change your APIs to use the
Authorization header with
Bearer scheme format and replace your existing API key with the manually generated access token to ensure your API calls work without automating the steps to request the access token.
When you feel confident that your API system handles the access token successfully, it’s time to add the steps to request it from within your code. Your tech stack may have SDKs to assist with at least part (or all) of the request steps. Auth0's SDKs can handle the OAuth 2.0 handshake on your behalf. Java Spring users may want to check out Spring Security documentation, and .NET Core users may want to check out Microsoft.AspNetCore.Authentication as examples. Remember to test your work as you go along for both happy path, error cases, and scenarios such as correctly handling access token expiration.
Before deploying changes to your production environment, ensure you’re using production-quality tools for generating the JWKS internally and managing your private key securely.
Best Practices during API Authorization Migrations
Immediately transitioning away from API keys isn’t always feasible, especially in large organizations or if you have a lot of APIs to update. You want to mitigate risks until you can transition to OAuth 2.0. Best practices include:
- Implement new API calls or code using OAuth 2.0.
- Follow the principle of least privilege. For instance:
- Don’t use personal accounts to establish service accounts.
- Use a custom admin role for service accounts, granting only the essential permissions.
- Use Groups and Global Session Policies to prevent interactive logins for service accounts.
Secure API Calls with OAuth 2.0
Shifting from static API keys to OAuth 2.0 isn’t only about embracing new technology but pivoting towards a more secure, flexible, and developer-friendly ecosystem. While API keys had their time and place, it’s clear that the future lies in dynamic, user-centric, and secure OAuth 2.0 solutions.
Remember, as technology progresses, staying updated and adapting to better practices isn’t just beneficial— it’s essential. Migrate to OAuth 2.0 and step into a more secure, streamlined, and efficient developer journey.
Next Steps with OAuth 2.0
If you find this post interesting, you may enjoy these resources: