background-shape
API Keys vs OAuth for Third-Party Access
November 18, 2022 · 4 min read · by Muhammad Amal programming

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:

  1. User creates new key with same scopes
  2. Updates their integration to use new key
  3. Confirms working
  4. 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.