Back to all articles
API Key Authentication

How to Implement API Key Authentication: A Complete Guide

February 26, 2026

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 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:

CriteriaAPI KeysOAuth 2.0JWTs
ComplexityLowHighMedium
Best forServer-to-server, developer APIsUser-delegated access, third-party appsStateless auth, microservices
Identity granularityPer application or consumerPer user and applicationPer user or service
RevocationImmediate (check on each request)Token expiry or revocation listRequires revocation list or short TTL
Setup timeMinutesHours to daysHours

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.

How API Keys Work

The flow for API key authentication is refreshingly simple:

text
┌────────────┐                          ┌────────────┐
│   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:

TypeScripttypescript
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

TypeScripttypescript
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:

TypeScripttypescript
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.

TypeScripttypescript
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:

JSONjson
{
  "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:

JSONjson
{
  "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:

AlgorithmProsCons
Fixed windowSimple to implementBurst at window boundaries
Sliding windowSmooth distributionMore memory and computation
Token bucketAllows controlled burstsSlightly more complex
Leaky bucketSteady output rateNo 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:

JSONjson
{
  "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 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:

TypeScripttypescript
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 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:

JSONjson
{
  "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.

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 and have API key authentication running on your API in minutes. Your developers (and your security team) will thank you.

Tags:#API Key Authentication#API Best Practices

Related Articles

Continue learning from the Zuplo Learning Center.

API Documentation

Developer Portal Comparison: Customization, Documentation, and Self-Service

Compare developer portal platforms — Zuplo/Zudoku, ReadMe, Redocly, Stoplight, and SwaggerHub — across customization, auto-generated docs, self-service API keys, and theming.

Model Context Protocol

Create an MCP Server from Your OpenAPI Spec in 5 Minutes

Turn any OpenAPI spec into a working MCP server with Zuplo — no custom code required. Follow this step-by-step tutorial to deploy in under 5 minutes.

On this page

When to Use API Keys vs. OAuth or JWTsHow API Keys WorkGenerating Secure API KeysSecure Storage: Never Store Plain Text KeysValidating API KeysDeclaring API Key Auth in OpenAPIPer-Key Rate LimitingKey RotationAPI Key Security ChecklistImplementing API Key Authentication with ZuploStart Securing Your API Today

Scale your APIs with
confidence.

Start for free or book a demo with our team.
Book a demoStart for Free
SOC 2 TYPE 2High Performer Spring 2025Momentum Leader Spring 2025Best Estimated ROI Spring 2025Easiest To Use Spring 2025Fastest Implementation Spring 2025

Get Updates From Zuplo

Zuplo logo
© 2026 zuplo. All rights reserved.
Products & Features
API ManagementAI GatewayMCP ServersMCP GatewayDeveloper PortalRate LimitingOpenAPI NativeGitOpsProgrammableAPI Key ManagementMulti-cloudAPI GovernanceMonetizationSelf-Serve DevX
Developers
DocumentationBlogLearning CenterCommunityChangelogIntegrations
Product
PricingSupportSign InCustomer Stories
Company
About UsMedia KitCareersStatusTrust & Compliance
Privacy PolicySecurity PoliciesTerms of ServiceTrust & Compliance
Docs
Pricing
Sign Up
Login
ContactBook a demoFAQ
Zuplo logo
DocsPricingSign Up
Login