---
title: "How to Implement API Key Authentication: A Complete Guide"
description: "Learn how to implement API key authentication from scratch — generation, secure storage, validation, rotation, and per-key rate limiting with practical code examples."
canonicalUrl: "https://zuplo.com/learning-center/how-to-implement-api-key-authentication"
pageType: "learning-center"
authors: "nate"
tags: "API Key Authentication, API Best Practices"
image: "https://zuplo.com/og?text=How%20to%20Implement%20API%20Key%20Authentication%3A%20A%20Complete%20Guide"
---
API key authentication is one of the oldest and most widely used methods for
securing APIs. Despite the rise of OAuth 2.0, JWTs, and other token-based
protocols, API keys remain the go-to choice for a huge number of APIs — and for
good reason. They are simple to understand, easy to implement, and
straightforward for your API consumers to use.

In this guide, we will walk through everything you need to know to implement API
key authentication properly: from generating cryptographically secure keys, to
storing them safely, validating requests, handling rotation, and adding per-key
rate limiting. Whether you are building a public API, an internal service, or a
developer platform, the patterns here will help you ship a secure, production
ready key management system.

If you are still deciding which authentication method is right for your API,
take a look at our
[comparison of the top 7 API authentication methods](/learning-center/top-7-api-authentication-methods-compared)
for a broader overview.

## When to Use API Keys vs. OAuth or JWTs

Before diving into implementation, it is worth understanding where API keys fit
in the authentication landscape. Here is a quick comparison:

| Criteria                 | API Keys                          | OAuth 2.0                               | JWTs                                  |
| ------------------------ | --------------------------------- | --------------------------------------- | ------------------------------------- |
| **Complexity**           | Low                               | High                                    | Medium                                |
| **Best for**             | Server-to-server, developer APIs  | User-delegated access, third-party apps | Stateless auth, microservices         |
| **Identity granularity** | Per application or consumer       | Per user and application                | Per user or service                   |
| **Revocation**           | Immediate (check on each request) | Token expiry or revocation list         | Requires revocation list or short TTL |
| **Setup time**           | Minutes                           | Hours to days                           | Hours                                 |

API keys are the right choice when:

- Your API consumers are **other services or backend applications**, not end
  users in a browser.
- You need a **simple onboarding flow** — give the developer a key and they are
  up and running.
- You want to **identify and rate-limit** individual consumers without the
  overhead of an OAuth authorization server.
- You are building a **developer platform** where each consumer gets their own
  credentials.

API keys are _not_ ideal when you need user-level delegation (e.g., "this app
can read my profile but not post on my behalf") — that is where OAuth shines.

For a deeper look at API key authentication patterns, see our
[API key authentication guide](/blog/api-key-authentication).

## How API Keys Work

The flow for API key authentication is refreshingly simple:

```
┌────────────┐                          ┌────────────┐
│   Client    │                          │  API Server │
│ (consumer)  │                          │             │
└─────┬──────┘                          └──────┬──────┘
      │                                        │
      │  1. Send request with API key           │
      │  ──────────────────────────────────────>│
      │    Authorization: Bearer zpka_abc123... │
      │                                        │
      │                                 2. Extract key from header
      │                                 3. Hash the key
      │                                 4. Look up hash in database
      │                                 5. Check permissions & limits
      │                                        │
      │  6. Return response (200 or 401)       │
      │  <──────────────────────────────────────│
      │                                        │
```

Here is what happens at each step:

1. **Client sends a request** with the API key in a header. The most common
   patterns are `Authorization: Bearer <key>` or a custom header like
   `X-API-Key: <key>`.
2. **The server extracts** the key from the incoming request.
3. **The server hashes** the key using a one-way hash function (like SHA-256).
4. **The hash is looked up** in the database to find the matching consumer
   record.
5. **Permissions and rate limits** are checked against the consumer's
   configuration.
6. **The server responds** — either with the requested data (200) or an
   authentication error (401/403).

The key insight is that the server never stores the raw API key. It only stores
a hash. This means even if your database is compromised, the attacker cannot use
the hashes to make API calls.

## Generating Secure API Keys

A good API key needs to be long enough that it cannot be guessed or
brute-forced, and structured so that it is easy for developers to identify and
manage.

### Entropy Requirements

Your API key should have at least 128 bits of entropy. For reference, a UUID v4
has 122 bits of randomness — close, but not quite ideal. A 32-byte random value
gives you 256 bits of entropy, which is more than sufficient.

### Key Structure and Prefixes

A common best practice is to add a prefix to your API keys. This serves several
purposes:

- **Identification**: Developers (and secret scanners like GitHub's) can
  immediately tell what service the key belongs to.
- **Versioning**: You can change the prefix when you change your key format.
- **Routing**: In a multi-tenant system, the prefix can indicate the environment
  or region.

For example, Zuplo uses the prefix `zpka_` for API keys. Stripe uses `sk_live_`
and `sk_test_`. Pick a prefix that is short, unique to your service, and
indicates the key type.

### Code Examples

Here is how to generate a secure API key in TypeScript:

```typescript
import { randomBytes } from "node:crypto";

function generateApiKey(prefix: string = "myapi"): string {
  // 32 bytes = 256 bits of entropy
  const randomPart = randomBytes(32).toString("base64url");
  return `${prefix}_${randomPart}`;
}

// Example output: myapi_k7Hj9mNqR2xYpL4wVbD8cE1fA3gT6iU0sK5nO9rW_Q
const key = generateApiKey();
console.log(key);
```

And the equivalent in Python:

```python
import secrets
import base64

def generate_api_key(prefix: str = "myapi") -> str:
    # 32 bytes = 256 bits of entropy
    random_bytes = secrets.token_bytes(32)
    random_part = base64.urlsafe_b64encode(random_bytes).rstrip(b"=").decode()
    return f"{prefix}_{random_part}"

# Example output: myapi_k7Hj9mNqR2xYpL4wVbD8cE1fA3gT6iU0sK5nO9rW_Q
key = generate_api_key()
print(key)
```

A few important notes:

- Always use a **cryptographically secure** random number generator
  (`crypto.randomBytes` or `secrets.token_bytes`), never `Math.random()` or
  Python's `random` module.
- Use **base64url** encoding (not hex) to keep keys shorter while preserving
  entropy.
- The full key (prefix + random part) is what you give to the developer. You
  will only store a hash of it on your end.

## Secure Storage: Never Store Plain Text Keys

This is the most critical rule of API key management: **never store API keys in
plain text**. If your database is compromised, plain text keys give attackers
instant access to every one of your consumer's accounts.

Instead, hash each key with SHA-256 before storing it. SHA-256 is a good choice
here (over bcrypt or argon2) because:

- API keys have high entropy (unlike passwords), so brute-force attacks against
  the hash are impractical.
- SHA-256 is fast, which matters when you are validating keys on every single
  API request.
- It produces a fixed-length output that is easy to index in your database.

### Storage Schema

Here is an example database schema for storing API keys:

```sql
CREATE TABLE api_keys (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    key_hash      VARCHAR(64) NOT NULL UNIQUE,  -- SHA-256 hex digest
    key_prefix    VARCHAR(20) NOT NULL,          -- e.g., "myapi_k7Hj"
    consumer_id   UUID NOT NULL REFERENCES consumers(id),
    label         VARCHAR(255),                  -- human-readable name
    scopes        TEXT[],                        -- permissions
    rate_limit    INTEGER DEFAULT 1000,          -- requests per minute
    expires_at    TIMESTAMP WITH TIME ZONE,
    created_at    TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    last_used_at  TIMESTAMP WITH TIME ZONE,
    is_active     BOOLEAN DEFAULT TRUE
);

CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);
```

Notice a few things:

- The `key_hash` column stores the SHA-256 hash, not the raw key.
- The `key_prefix` stores the first few characters of the key. This allows you
  to show a partial key in your dashboard (e.g., "myapi_k7Hj...") so consumers
  can identify which key is which, without exposing the full key.
- Each key has its own `rate_limit`, `scopes`, and `expires_at` — giving you
  fine-grained control per consumer.

### Hashing Example

```typescript
import { createHash } from "node:crypto";

function hashApiKey(key: string): string {
  return createHash("sha256").update(key).digest("hex");
}

// When creating a key:
const rawKey = generateApiKey();
const hash = hashApiKey(rawKey);

// Store `hash` in the database
// Return `rawKey` to the developer (this is the only time they see it)
```

```python
import hashlib

def hash_api_key(key: str) -> str:
    return hashlib.sha256(key.encode()).hexdigest()

# When creating a key:
raw_key = generate_api_key()
key_hash = hash_api_key(raw_key)

# Store `key_hash` in the database
# Return `raw_key` to the developer (this is the only time they see it)
```

The developer sees the raw key exactly once — when it is first created. After
that, you only ever work with the hash.

## Validating API Keys

When a request comes in, you need to extract the key, hash it, look it up, and
verify it is still valid. Here is a middleware pattern for a Node.js/Express
application:

```typescript
import { createHash, timingSafeEqual } from "node:crypto";

interface ApiKeyRecord {
  id: string;
  keyHash: string;
  consumerId: string;
  scopes: string[];
  rateLimit: number;
  expiresAt: Date | null;
  isActive: boolean;
}

async function validateApiKey(request: Request): Promise<ApiKeyRecord | null> {
  // 1. Extract the key from the Authorization header
  const authHeader = request.headers.get("authorization");
  if (!authHeader?.startsWith("Bearer ")) {
    return null;
  }
  const apiKey = authHeader.slice(7);

  // 2. Hash the incoming key
  const keyHash = createHash("sha256").update(apiKey).digest("hex");

  // 3. Look up the hash in the database
  const record = await db.query<ApiKeyRecord>(
    "SELECT * FROM api_keys WHERE key_hash = $1",
    [keyHash],
  );

  if (!record) {
    return null;
  }

  // 4. Check if the key is active and not expired
  if (!record.isActive) {
    return null;
  }

  if (record.expiresAt && new Date() > record.expiresAt) {
    return null;
  }

  // 5. Update last_used_at (fire and forget)
  db.query("UPDATE api_keys SET last_used_at = NOW() WHERE id = $1", [
    record.id,
  ]).catch(() => {});

  return record;
}
```

And a similar pattern in Python with FastAPI:

```python
from fastapi import FastAPI, Request, HTTPException, Depends
from datetime import datetime, timezone
import hashlib

app = FastAPI()

async def get_api_key(request: Request):
    auth_header = request.headers.get("authorization", "")
    if not auth_header.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Missing API key")

    api_key = auth_header[7:]
    key_hash = hashlib.sha256(api_key.encode()).hexdigest()

    record = await db.fetch_one(
        "SELECT * FROM api_keys WHERE key_hash = :hash AND is_active = true",
        {"hash": key_hash},
    )

    if not record:
        raise HTTPException(status_code=401, detail="Invalid API key")

    if record.expires_at and datetime.now(timezone.utc) > record.expires_at:
        raise HTTPException(status_code=401, detail="API key expired")

    return record

@app.get("/api/data")
async def get_data(key_record = Depends(get_api_key)):
    return {"message": "Authenticated", "consumer": key_record.consumer_id}
```

### A Note on Constant-Time Comparison

You might notice that we are comparing hashes using a database query rather than
comparing strings directly in application code. When the database finds (or does
not find) a matching hash, the timing is determined by the database index
lookup, which does not leak information about how many characters matched.

If you ever need to compare hashes directly in application code, always use a
constant-time comparison function like `crypto.timingSafeEqual` in Node.js or
`hmac.compare_digest` in Python. Standard string equality (`===` or `==`) can
leak information through timing side channels because it short-circuits on the
first mismatched character.

```typescript
import { timingSafeEqual } from "node:crypto";

function safeCompare(a: string, b: string): boolean {
  const bufA = Buffer.from(a);
  const bufB = Buffer.from(b);
  if (bufA.length !== bufB.length) return false;
  return timingSafeEqual(bufA, bufB);
}
```

## Declaring API Key Auth in OpenAPI

If you are building an API with an OpenAPI specification (and you should be),
here is how to declare API key authentication using the `securitySchemes`
component:

```json
{
  "openapi": "3.1.0",
  "info": {
    "title": "My API",
    "version": "1.0.0"
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "Authorization",
        "description": "API key passed as a Bearer token in the Authorization header."
      }
    }
  },
  "security": [
    {
      "ApiKeyAuth": []
    }
  ],
  "paths": {
    "/api/data": {
      "get": {
        "summary": "Get data",
        "security": [{ "ApiKeyAuth": [] }],
        "responses": {
          "200": {
            "description": "Successful response"
          },
          "401": {
            "description": "Unauthorized - invalid or missing API key"
          }
        }
      }
    }
  }
}
```

The `securitySchemes` definition tells tooling (like API documentation
generators, SDKs, and testing tools) that your API expects an API key. The
`security` array at the top level applies the scheme globally, while you can
override it per operation if needed.

If you prefer using a custom header (like `X-API-Key`), simply change the `name`
field:

```json
{
  "securitySchemes": {
    "ApiKeyAuth": {
      "type": "apiKey",
      "in": "header",
      "name": "X-API-Key"
    }
  }
}
```

## Per-Key Rate Limiting

Rate limiting is essential for protecting your API from abuse, and doing it
per-key (rather than just per-IP) gives you much finer control. Per-key rate
limiting lets you:

- **Set different limits** for different tiers of consumers (free vs. paid).
- **Identify abusive consumers** directly, even if they rotate IP addresses.
- **Enforce usage quotas** tied to billing or subscription plans.

### Rate Limiting Strategies

There are several algorithms you can use for rate limiting:

| Algorithm          | Pros                     | Cons                        |
| ------------------ | ------------------------ | --------------------------- |
| **Fixed window**   | Simple to implement      | Burst at window boundaries  |
| **Sliding window** | Smooth distribution      | More memory and computation |
| **Token bucket**   | Allows controlled bursts | Slightly more complex       |
| **Leaky bucket**   | Steady output rate       | No bursts allowed           |

For most APIs, a sliding window or token bucket approach provides the best
balance between fairness and flexibility.

### Rate Limiting with Zuplo

If you are using Zuplo as your API gateway, configuring per-key rate limiting is
straightforward. You can add a rate limiting policy directly in your route
configuration:

```json
{
  "export": "RateLimitInboundPolicy",
  "module": "$import(@zuplo/runtime)",
  "options": {
    "rateLimitBy": "user",
    "requestsAllowed": 1000,
    "timeWindowMinutes": 1
  }
}
```

Setting `rateLimitBy` to `"user"` means the rate limit is applied per
authenticated API key consumer. Each consumer gets their own bucket of 1000
requests per minute. You can also use Zuplo's
[API key management](https://zuplo.com/docs/articles/api-key-management) to set
different rate limits for different consumers directly in the dashboard, no code
required.

## Key Rotation

API keys get leaked. Developers accidentally commit them to GitHub, paste them
in Slack, or leave them in logs. Having a solid key rotation strategy is not
optional — it is a necessity.

### Rotation Strategies

There are two main approaches to key rotation:

**1. Grace Period Rotation**

This is the most common and developer-friendly approach. When a consumer
requests a new key:

1. Generate a new key and store its hash.
2. Mark the old key as "expiring" with a grace period (e.g., 24-72 hours).
3. Both keys work during the grace period.
4. After the grace period, the old key is automatically deactivated.

This gives the consumer time to update their integration without any downtime.

**2. Multiple Active Keys**

Allow each consumer to have multiple active keys at the same time (typically two
to three). This way, the consumer can:

1. Create a new key.
2. Deploy their application with the new key.
3. Verify everything works.
4. Delete the old key.

This is the approach used by services like AWS (which provides two access key
slots) and is the safest option because the consumer controls the timing.

### Implementation Pattern

Here is how you might implement the multiple active keys approach:

```typescript
async function rotateApiKey(consumerId: string): Promise<{
  newKey: string;
  message: string;
}> {
  // Check how many active keys the consumer already has
  const activeKeys = await db.query(
    "SELECT COUNT(*) as count FROM api_keys WHERE consumer_id = $1 AND is_active = true",
    [consumerId],
  );

  if (activeKeys.count >= 3) {
    throw new Error(
      "Maximum of 3 active keys allowed. Please deactivate an existing key first.",
    );
  }

  // Generate and store the new key
  const rawKey = generateApiKey();
  const keyHash = hashApiKey(rawKey);
  const keyPrefix = rawKey.slice(0, 12);

  await db.query(
    `INSERT INTO api_keys (key_hash, key_prefix, consumer_id, label)
     VALUES ($1, $2, $3, $4)`,
    [keyHash, keyPrefix, consumerId, `Key created ${new Date().toISOString()}`],
  );

  return {
    newKey: rawKey,
    message:
      "New key created. Your old key(s) remain active. " +
      "Delete old keys once you have updated your integration.",
  };
}

async function deactivateApiKey(
  consumerId: string,
  keyId: string,
): Promise<void> {
  // Ensure the consumer cannot deactivate their last key
  const activeKeys = await db.query(
    "SELECT COUNT(*) as count FROM api_keys WHERE consumer_id = $1 AND is_active = true",
    [consumerId],
  );

  if (activeKeys.count <= 1) {
    throw new Error(
      "Cannot deactivate your last active key. Create a new key first.",
    );
  }

  await db.query(
    "UPDATE api_keys SET is_active = false WHERE id = $1 AND consumer_id = $2",
    [keyId, consumerId],
  );
}
```

## API Key Security Checklist

Here is a comprehensive checklist to make sure your API key implementation
follows security best practices:

- **Always use HTTPS.** API keys sent over plain HTTP can be intercepted by
  anyone on the network. There are no exceptions to this rule.
- **Never store keys in plain text.** Hash all keys with SHA-256 before storing
  them in your database.
- **Never log API keys.** Scrub keys from your application logs, access logs,
  and error reports. Log the key prefix or a key ID instead.
- **Set expiration dates.** Keys should not live forever. Set a default
  expiration (e.g., 90 days or one year) and notify consumers before their keys
  expire.
- **Support key rotation.** Allow consumers to create new keys and deactivate
  old ones without downtime.
- **Use key prefixes.** Prefixes make keys identifiable and enable secret
  scanning tools to detect leaked keys.
- **Implement per-key rate limiting.** Protect your API from abuse and ensure
  fair usage across consumers.
- **Use the Authorization header.** Prefer `Authorization: Bearer <key>` over
  query string parameters. Query strings get logged in web servers, proxies, and
  browser history.
- **Never embed keys in client-side code.** API keys in JavaScript bundles, iOS
  apps, or Android APKs are trivially extractable. Keys should only be used in
  server-to-server communication.
- **Monitor for leaked keys.** Use tools like GitHub secret scanning or
  GitGuardian to detect keys that have been accidentally committed to
  repositories.
- **Scope keys to minimum permissions.** Each key should only have access to the
  endpoints and actions it needs. Follow the principle of least privilege.
- **Provide a key management dashboard.** Give consumers visibility into their
  keys, including creation dates, last used timestamps, and the ability to
  create and revoke keys.

## Implementing API Key Authentication with Zuplo

Building all of the above from scratch is a significant amount of work. You need
to handle key generation, hashing, storage, validation on every request, rate
limiting, rotation, a consumer dashboard, and ongoing maintenance.

[Zuplo](https://zuplo.com) provides a fully managed API key authentication
service that handles all of this out of the box. Here is what you get:

**Automatic key generation and storage.** Zuplo generates cryptographically
secure API keys with configurable prefixes and stores them securely. You never
have to manage a keys database yourself.

**Built-in validation.** Add API key authentication to any route with a single
policy — no custom middleware required:

```json
{
  "export": "ApiKeyInboundPolicy",
  "module": "$import(@zuplo/runtime)",
  "options": {
    "allowUnauthenticated": false
  }
}
```

**Per-consumer rate limiting.** Set rate limits per consumer directly in the
Zuplo dashboard or API. Different consumers can have different limits based on
their plan or tier.

**Self-serve developer portal.** Zuplo automatically generates a developer
portal where your API consumers can sign up, create API keys, view their usage,
and rotate keys — all without you writing a single line of portal code.

**OpenAPI integration.** Zuplo reads your OpenAPI specification and
automatically applies the correct security schemes, generates documentation, and
validates requests.

**Key rotation and management.** Consumers can create multiple keys and
deactivate old ones through the developer portal. You get full audit logs of
every key event.

**Secret scanning integration.** Zuplo integrates with GitHub's secret scanning
program, so if a consumer accidentally pushes their API key to a public
repository, the key can be automatically revoked.

To learn more about how Zuplo handles API key management, check out the
[Zuplo API key management documentation](https://zuplo.com/docs/articles/api-key-management).

## Start Securing Your API Today

API key authentication, when done right, is a powerful and practical way to
secure your API. The key (pun intended) is to follow the fundamentals: generate
keys with sufficient entropy, never store them in plain text, validate on every
request, support rotation, and rate limit per consumer.

If you want to skip the custom implementation work and get all of this out of
the box, [sign up for a free Zuplo account](https://portal.zuplo.com) and have
API key authentication running on your API in minutes. Your developers (and your
security team) will thank you.