API Keys vs OAuth 2.0 vs JWT: How I Actually Choose (After Getting It Wrong Twice)
I've shipped authentication three different ways across three different services, and I got it wrong twice. Not "wrong" as in broken — wrong as in needlessly complicated, which is worse, because nobody notices until a new engineer spends a week trying to onboard.
Here's the short version of what I learned: API keys, OAuth 2.0, and JWT are not three options on the same menu. One is a credential, one is an authorization framework, and one is a token format. Comparing them directly is like comparing "a password" to "a login flow" to "a piece of paper."
Once that clicked, the decisions got easy. Let me walk through how I actually choose now.
Start with two questions, not with a technology
Before touching any auth library, answer these:
- Who is the caller? A server you control? A third-party app acting for one of your users? A browser holding a session?
- Who decides what they're allowed to do? You (static permissions), the user (consent screen), or an external identity provider?
If the answer to (1) is "another backend" and (2) is "me", you want an API key. That's the whole decision. Everything below is just detail.
API keys: the boring answer that's usually right
An API key is a long random string that identifies the caller. That's it. It carries no user identity, no scopes, no expiry unless you add one.
I use API keys when:
- machine-to-machine calls happen between services I control
- the permission model is coarse ("this key can read, that key can write")
- I want to be able to revoke access in one database update
What most teams get wrong is storage. They hash user passwords properly and then store API keys in plaintext, because "it's just a key." If your database leaks, those keys are directly usable. Treat them like passwords: store a hash, show the raw value exactly once at creation.
import hashlib
import secrets
def create_api_key(conn, owner_id: str, scope: str) -> str:
"""Returns the raw key ONCE. Only the hash is persisted."""
raw = f"sk_{secrets.token_urlsafe(32)}"
key_hash = hashlib.sha256(raw.encode()).hexdigest()
conn.execute(
"INSERT INTO api_keys (key_hash, owner_id, scope, created_at, revoked) "
"VALUES (?, ?, ?, datetime('now'), 0)",
(key_hash, owner_id, scope),
)
conn.commit()
return raw
Lookup is a single indexed query on key_hash. No secrets at rest, no encrypted-column gymnastics, and revocation is just flipping a flag.
I also give every key a prefix column (the first 8 characters, stored in plaintext). It's useless to an attacker and it lets you search logs by key without ever seeing the secret. When someone says "which integration is hammering us?", you can answer in ten seconds.
Where API keys fall apart: the moment a request needs to say which user it's for. A key identifies the app, not the person. The usual workaround — passing a user ID alongside the key — means anyone with the key can impersonate anyone. That's where the next option starts.
OAuth 2.0: for when someone else's user is involved
OAuth 2.0 exists to solve one specific problem: letting a third-party application access a user's data without the user handing over their password.
If your service never has third-party apps acting on behalf of your users, you probably don't need OAuth. This is the mistake I made the first time — I built a full authorization-code flow with a consent screen for an internal admin panel where every user was an employee and every permission was static. Six weeks of work that a table of API keys would have replaced.
If you do need it, the parts that matter in practice:
- Authorization Code + PKCE is the only flow I'd use for anything user-facing. The implicit flow is dead. Client credentials is fine, but that's machine-to-machine — see the API keys section above and ask whether OAuth buys you anything.
-
Scope design is your real API design.
readis a bad scope.orders:readis a good one. Scopes should map to things a user can understand on a consent screen, because that's exactly where they'll be shown. - Refresh token rotation is not optional. Issue a new refresh token every refresh, invalidate the old one, and treat reuse of a stale refresh token as a compromise signal worth logging loudly.
The cost nobody mentions: OAuth means you now maintain a consent UI, a token store, a revocation path, and a support burden for "why did this app show up in my account?" That's a fair price for third-party access. It's a terrible price for authenticating your own mobile app.
JWT: a format, not an authentication strategy
This is where the second mistake lived.
A JWT is just a signed JSON blob. It is not a protocol, not a session system, and not automatically more secure than an opaque token. The appeal is real: the server can verify a token without a database round trip, because the signature proves the contents.
The trap is what you do after that.
Mistake I made: I used JWTs as the session mechanism and set a 30-day expiry to avoid forcing re-logins. That meant a user who was banned, demoted, or who changed their password still had a valid token for up to a month. There is no "log out everywhere" with a self-contained token unless you build revocation — and the moment you build a revocation list you're doing a database lookup on every request, which was the whole reason you picked JWT.
So here's my rule now:
| Situation | What I use | Why |
|---|---|---|
| Server-to-server, static permissions | API key (hashed at rest) | Simplest thing that works, instant revocation |
| Third-party app acting for a user | OAuth 2.0 + PKCE | Built for consent and delegated access |
| Short-lived token between services | JWT with 5–15 min expiry | Verification without a shared session store |
| Long-lived user session | Opaque token in a server-side session | Revocation is a single delete |
| Browser session | HttpOnly, Secure, SameSite cookie | Never let JavaScript touch the credential |
The honest summary: JWT is an optimization for verification, not a replacement for state. Use short expiries and accept that revocation happens at the next refresh, or use opaque tokens and keep a session table. Don't use a 30-day JWT and pretend the problem doesn't exist.
The details that actually cause incidents
A few things I now check on every service, regardless of which option won:
-
Timing-safe comparison. Comparing secrets with
==leaks information through response timing. Usehmac.compare_digestor your language's equivalent. It's one line. -
Keys in logs. Most auth incidents I've seen weren't database breaches — they were a key printed in a log line, then shipped to a log aggregator with a wider audience than the database. Redact
Authorizationheaders before logging, always. - No auth in query strings. Query strings land in access logs, browser history, and referrer headers. This is the single most common way a working system leaks credentials on day one.
-
Expiry you can actually enforce. A
expires_atcolumn that nothing reads is decoration. If a key can be rotated, rotate it — quarterly, automatically, and make rotation a boring scheduled task rather than a quarterly panic.
The checklist I use now
- Is the caller a service I control with static permissions? → API key, hashed at rest, with a plaintext prefix for log searching.
- Is a third party acting on behalf of a user? → OAuth 2.0, authorization code + PKCE, narrow scopes, rotating refresh tokens.
- Do I need verification without a shared store, on a timescale of minutes? → JWT, short expiry.
- Is this a user session that must be revocable immediately? → Opaque token, server-side session table.
Two of my three services ended up with API keys. The one that needed OAuth genuinely needed it. Nobody's implementation was made better by adding a token format it didn't have a problem for.
If you're staring at a design right now and the answer to question one is "another backend," stop reading comparison posts (including this one) and write the hashed-key table. You'll be done before lunch.
Top comments (0)