API Keys vs OAuth for Third-Party Access
TL;DR — API keys for server-to-server access where one entity owns the key (your tenant, your integration). OAuth for user-delegated access (third-party app accesses USER’S data). Hybrid: API keys with scopes look like OAuth without the dance.
After Redis rate limiting, an architectural question that comes up regularly: when offering API access to third parties, what auth mechanism?
What each one is
API key: long-lived secret string. Server issues; client sends in header. Identifies the client.
OAuth (delegated): user authorizes a third-party app to access user’s data on user’s behalf. Third-party app never sees user’s password.
Same surface (HTTP API with auth header) but very different semantics.
When API keys win
Server-to-server scenarios where one organization owns the integration:
- Stripe webhooks: Stripe → your API
- A customer’s backend calling your API
- Internal microservice → microservice (use mTLS or service mesh, but API keys also work)
- Slack incoming webhooks
- IoT devices reporting to a backend
The relationship: “this entity (your customer / their integration / your own service) is authenticated.”
Properties:
- Stable identity (the key doesn’t change per request)
- Server-side stored on both sides
- Revocable (delete the key)
- Scoped (the key has limited permissions)
When OAuth wins
User-delegated access:
- “Allow this third-party app to read my Google Drive files”
- “Allow this Slack bot to post to my workspace”
- “Allow this CRM to sync with my Gmail”
The user authorizes a third party to act on their behalf. The third party never sees the user’s password.
Properties:
- Per-user grant (each user authorizes separately)
- Granular scopes
- Revocable per-user (user can revoke without affecting other users)
- Token-based with refresh
For your API: if external apps will act on behalf of your users, you offer OAuth.
The Hybrid: scoped API keys
Modern API key implementations look like OAuth without the dance:
Key: sk_live_abc123xyz
Owner: org_42
Scopes: ['orders:read', 'customers:read']
Created: 2022-11-18
Expires: never (or per-policy)
Rotated_at: -
Stored in your DB. Each request, look up the key, check scopes against the requested resource.
CREATE TABLE api_keys (
id text PRIMARY KEY, -- hash of the key
prefix text NOT NULL, -- first 8 chars of key for display
owner_id bigint NOT NULL,
scopes text[] NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
last_used_at timestamptz,
expires_at timestamptz,
revoked_at timestamptz
);
Store the HASH of the key, not the key itself. Compromise of the DB doesn’t reveal usable keys. Show only the prefix in dashboards (e.g., sk_live_abc1...).
Generating API keys
import secrets
def generate_api_key(prefix: str = "sk_live") -> tuple[str, str]:
random_part = secrets.token_urlsafe(32)
full_key = f"{prefix}_{random_part}"
key_hash = hashlib.sha256(full_key.encode()).hexdigest()
return full_key, key_hash
Show full_key to user ONCE (e.g., in dashboard, “save this somewhere safe”). Store key_hash in DB.
If user loses the key, generate new. Never email keys.
Verifying
def verify_api_key(key: str) -> dict | None:
key_hash = hashlib.sha256(key.encode()).hexdigest()
row = db.execute(
"SELECT * FROM api_keys WHERE id = %s AND revoked_at IS NULL "
"AND (expires_at IS NULL OR expires_at > now())",
(key_hash,)
).fetchone()
if not row:
return None
db.execute("UPDATE api_keys SET last_used_at = now() WHERE id = %s", (key_hash,))
return {"owner_id": row.owner_id, "scopes": row.scopes}
In a middleware:
@app.middleware
def auth_middleware(req, next):
key = req.headers.get("Authorization", "").removeprefix("Bearer ")
if not key.startswith("sk_"):
return forbidden()
ctx = verify_api_key(key)
if not ctx:
return forbidden()
req.context = ctx
return next(req)
Scopes
Scopes are explicit per-key permissions:
SCOPES = {
"orders:read",
"orders:write",
"customers:read",
"customers:write",
"admin",
}
def require_scope(scope: str):
def decorator(handler):
def wrapper(req):
if scope not in req.context["scopes"]:
return forbidden()
return handler(req)
return wrapper
return decorator
@require_scope("orders:read")
def list_orders(req):
return orders.filter(owner_id=req.context["owner_id"])
Users create keys with specific scopes. A “read-only” key can’t make destructive changes even if compromised.
Convention: <resource>:<action> where action ∈ {read, write, admin}. Familiar; predictable.
Rotation
API keys should be rotatable without downtime:
- User creates new key with same scopes
- Updates their integration to use new key
- Confirms working
- Revokes old key
For high-security setups, expire keys after a max lifetime (90 days):
SELECT * FROM api_keys WHERE expires_at < now() + interval '7 days';
Notify users 7 days before expiry. Auto-revoke at expiry.
For Stripe-style: optional rotation. For SOC 2: mandatory periodic rotation.
Display in dashboards
Never display the full key after creation. Show only prefix:
Your API keys:
sk_live_abc1... (created 2 days ago, last used 1 min ago) [Revoke]
sk_live_xyz9... (created 30 days ago, last used 4 hours ago) [Revoke]
User who needs to copy a key creates a new one (with the same scopes if needed).
Common Pitfalls
Storing keys plaintext. DB breach = usable keys. Hash them.
Showing full keys after creation. User screenshots; key gets shared in chat. One-time display only.
No scopes. All keys have full admin access. Compromise of any key is catastrophic.
No revocation flow. User can’t kill a leaked key.
Same key reused across environments. Dev key works in prod. Environment-isolate.
No rotation alerts. Long-lived keys accumulate; nobody knows which to rotate.
API key in URL query. Logged everywhere. Always header.
Wrapping Up
API keys for server-to-server, scoped, hashed, revocable. OAuth for user-delegated. Both have their place. Monday: CORS — what it actually protects.