Resources / Tutorial

API keys vs OAuth2: picking one, and not getting burned

Two ways to authenticate to an API, when to use each, and how to handle them without leaving a hole.

One of the things I struggled with when I first started working with APIs was authentication. There were so many different ways to do it, and just as many opinions on how to use them. Every integration starts at the same fork: before you can pull a single record, the API wants to know who you are. Two approaches cover almost everything you'll run into, a plain API key or OAuth2. They aren't interchangeable, and most of the integration security problems I've seen trace back to one of two things: picking the wrong one, or handling the right one carelessly.

Here's how to tell them apart and use each without leaving a hole.

API keys

An API key is a long random string the provider issues to your account. You send it with every request, and the API treats it as "this is me." That's the whole idea. It's simple, and for a lot of server-to-server work it's all you need.

Most APIs want the key in a header:

curl -H "X-API-Key: 9f8b2c...redacted" https://api.example.com/v1/orders

Some use the Authorization header instead:

curl -H "Authorization: Bearer 9f8b2c...redacted" https://api.example.com/v1/orders

In C# it's the same idea. Set the header once on the client and make the call:

var http = new HttpClient();
http.DefaultRequestHeaders.Add("X-API-Key", apiKey);

var response = await http.GetAsync("https://api.example.com/v1/orders");
response.EnsureSuccessStatusCode();

One thing I won't do is put the key in the URL.

# don't do this
https://api.example.com/v1/orders?api_key=9f8b2c...

Query strings end up in server logs, browser history, and proxy caches. A key sitting in a log file is a key that's leaked. Keep it in a header.

The mental model that keeps you out of trouble: an API key is a password. It isn't "less secret" because it's for a machine. If it gets out, anyone holding it is you, until you rotate it. So treat it like a password. Keep it out of source control, out of screenshots, out of that quick Slack message to a teammate, and somewhere you can rotate fast.

API keys are the right call when:

  • you're calling your own account, server-side
  • the provider hands you a key and doesn't offer (or require) anything fancier
  • you don't need to act on behalf of someone else's account

OAuth2

OAuth2 shows up when a plain key isn't enough, usually because the access needs to be scoped, expiring, or done on behalf of a user. It has a few flows, and the one you'll use most for backend integrations is client credentials. That's the machine-to-machine version, and it's the one worth knowing cold.

The shape of it: you hold a client id and a client secret. Instead of sending those on every call, you trade them at a token endpoint for a short-lived access token, and you send that token with your requests. When it expires, you get a new one.

Step one, get a token:

curl -X POST https://auth.example.com/oauth/token \
  -d grant_type=client_credentials \
  -d client_id=your-client-id \
  -d client_secret=your-client-secret \
  -d scope=orders.read

You get back something like:

{
  "access_token": "eyJhbGciOi...",
  "token_type": "Bearer",
  "expires_in": 3600
}

Step two, use it:

curl -H "Authorization: Bearer eyJhbGciOi..." https://api.example.com/v1/orders

The piece people get wrong is treating the token like a key and fetching a fresh one on every call. Don't. The token is good for a while (expires_in is in seconds, so 3600 is an hour), so cache it and reuse it until it's close to expiring. Hammering the token endpoint on every request is slower, and a good way to get rate limited on auth itself.

A small token cache in C#:

public class TokenProvider
{
    private readonly HttpClient _http;
    private string? _token;
    private DateTimeOffset _expiresAt;

    public TokenProvider(HttpClient http) => _http = http;

    public async Task<string> GetTokenAsync()
    {
        // refresh a minute early so a call never goes out with a dead token
        if (_token is not null && DateTimeOffset.UtcNow < _expiresAt.AddMinutes(-1))
            return _token;

        var form = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            ["grant_type"] = "client_credentials",
            ["client_id"] = Config.ClientId,
            ["client_secret"] = Config.ClientSecret,
            ["scope"] = "orders.read"
        });

        var res = await _http.PostAsync("https://auth.example.com/oauth/token", form);
        res.EnsureSuccessStatusCode();

        var json = await res.Content.ReadFromJsonAsync<TokenResponse>();
        _token = json!.AccessToken;
        _expiresAt = DateTimeOffset.UtcNow.AddSeconds(json.ExpiresIn);
        return _token;
    }

    private record TokenResponse(
        [property: JsonPropertyName("access_token")] string AccessToken,
        [property: JsonPropertyName("expires_in")] int ExpiresIn);
}

That "refresh a minute early" line matters more than it looks. Clocks drift, requests take time, and a token that's technically valid when you check it can be dead by the time it lands. Giving yourself a small buffer kills a whole class of intermittent 401s that are miserable to track down.

There's also an authorization-code flow, which is what you use when a real person logs in and grants your app access to their account (those "Sign in with..." buttons). That's covered in its own article. For backend integration work, client credentials is usually what you want.

So which one

Quick version:

  • The API gives you a key and you're calling your own account from a server: use the key.
  • The API requires OAuth2, you're acting for end users, or you want short-lived scoped tokens: use OAuth2, client-credentials flow for machine-to-machine.

When an API supports both and lets me choose, I usually go with OAuth2 for anything that's going to run long-term. The reasoning is simple: access tokens expire. If one ever leaks, it stops working on its own after an hour or so. An API key doesn't expire, so if it leaks it keeps working until you happen to notice and shut it off. OAuth2 just leaves you a smaller window to worry about.

Where people get burned

Secrets in the codebase. The client secret or API key committed to git, even a private repo. Once it's in history it's there for good, and rotating the secret is the only real fix. Keep secrets in environment variables or a secrets manager, not in appsettings.json.

Keys in query strings. Covered above, worth repeating: header, not URL.

Fetching a token per request. Cache it. Refresh near expiry, not on every call.

Not handling the 401. Tokens expire and keys get rotated. Catch a 401, refresh, retry once. If it 401s again, now you've got a real auth problem to look at.

No rotation plan. Whatever holds the secret, you want to be able to swap it in under a minute without a code change. If rotating a leaked key means a deploy, you'll hesitate at the exact moment you shouldn't.

The thread through all of this is that the secret is the whole ballgame. Pick the auth the API supports, send it in a header, cache your tokens, and keep the secret somewhere you can change in a hurry. Do that and auth becomes the boring part of the integration, which is right where you want it.