Skip to main content
Technology & EngineeringSecurity Ratelimit369 lines

Unkey

API key management, rate limiting, usage tracking, key verification, temporary keys, ratelimit API, and analytics with Unkey

Quick Summary30 lines
Unkey is purpose-built infrastructure for API key management. Instead of rolling your own key table, hashing logic, and rate limiting middleware, Unkey provides a managed service where keys are created, verified, and revoked through a simple API. Every verification returns metadata, remaining quota, and rate limit status in a single round-trip. This lets you enforce per-key rate limits, track usage, issue temporary keys, and build analytics without maintaining any of that plumbing yourself.

## Key Points

- Always use a `prefix` when creating keys (e.g., `sk_`, `tmp_`) so users and logs can identify key types at a glance.
- Set `remaining` on keys to enforce hard usage caps independent of rate limits. Rate limits control speed; remaining controls total volume.
- Use `ownerId` on every key so you can list and revoke all keys for a user when needed.
- Store business logic in `meta` (plan tier, permissions) and read it on verification rather than maintaining a separate lookup table.
- Return standard `X-RateLimit-*` headers so API consumers can implement proper backoff.
- Use separate Ratelimit namespaces for different resource types (e.g., `api.read` vs. `api.write`) to apply distinct limits.
- For temporary access (trials, previews), use `expires` and `remaining` together so the key is bounded in both time and usage.
- **Storing the key plaintext in your own database.** Unkey handles hashing. You only need the `keyId` for management operations; never persist the raw key after returning it to the user.
- **Verifying keys without checking `result.valid`.** The API call can succeed (no `error`) but return `valid: false` for expired or revoked keys. Always check the boolean.
- **Using a single namespace for all rate limits.** This conflates different resources and makes it impossible to tune limits independently.
- **Creating keys without `ownerId`.** You lose the ability to query or revoke keys by user, making key management painful at scale.
- **Ignoring the `remaining` field.** If you set a usage quota, you must communicate the remaining count to the user or they have no way to plan their consumption.

## Quick Example

```typescript
npm install @unkey/api @unkey/ratelimit
```

```typescript
// .env.local
UNKEY_ROOT_KEY=unkey_yourRootKey
UNKEY_API_ID=api_yourApiId
```
skilldb get security-ratelimit-skills/UnkeyFull skill: 369 lines
Paste into your CLAUDE.md or agent config

Unkey: API Key Infrastructure

Core Philosophy

Unkey is purpose-built infrastructure for API key management. Instead of rolling your own key table, hashing logic, and rate limiting middleware, Unkey provides a managed service where keys are created, verified, and revoked through a simple API. Every verification returns metadata, remaining quota, and rate limit status in a single round-trip. This lets you enforce per-key rate limits, track usage, issue temporary keys, and build analytics without maintaining any of that plumbing yourself.

Setup

Installation

npm install @unkey/api @unkey/ratelimit

Environment Configuration

// .env.local
UNKEY_ROOT_KEY=unkey_yourRootKey
UNKEY_API_ID=api_yourApiId

Client Initialization

// lib/unkey.ts
import { Unkey } from "@unkey/api";

export const unkey = new Unkey({
  rootKey: process.env.UNKEY_ROOT_KEY!,
});

Key Techniques

Creating API Keys

// app/api/keys/create/route.ts
import { unkey } from "@/lib/unkey";
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const { userId, plan } = await req.json();

  const result = await unkey.keys.create({
    apiId: process.env.UNKEY_API_ID!,
    prefix: "sk",
    ownerId: userId,
    meta: {
      plan,
      createdBy: "api",
    },
    ratelimit: {
      type: "fast",
      limit: plan === "pro" ? 1000 : 100,
      refillRate: plan === "pro" ? 100 : 10,
      refillInterval: 60000, // ms
    },
    remaining: plan === "pro" ? 50000 : 5000,
  });

  if (result.error) {
    return NextResponse.json({ error: result.error.message }, { status: 500 });
  }

  return NextResponse.json({
    key: result.result.key,
    keyId: result.result.keyId,
  });
}

Verifying Keys

// lib/verify-key.ts
import { verifyKey } from "@unkey/api";

export async function verifyApiKey(key: string) {
  const result = await verifyKey({
    key,
    apiId: process.env.UNKEY_API_ID!,
  });

  if (result.error) {
    return { valid: false, error: result.error.message };
  }

  return {
    valid: result.result.valid,
    ownerId: result.result.ownerId,
    meta: result.result.meta,
    remaining: result.result.remaining,
    ratelimit: result.result.ratelimit,
  };
}

API Route with Key Verification

// app/api/protected/route.ts
import { verifyApiKey } from "@/lib/verify-key";
import { NextRequest, NextResponse } from "next/server";

export async function GET(req: NextRequest) {
  const authHeader = req.headers.get("authorization");
  if (!authHeader?.startsWith("Bearer ")) {
    return NextResponse.json({ error: "Missing API key" }, { status: 401 });
  }

  const key = authHeader.slice(7);
  const verification = await verifyApiKey(key);

  if (!verification.valid) {
    return NextResponse.json(
      { error: "Invalid or expired API key" },
      { status: 403 }
    );
  }

  if (verification.ratelimit && !verification.ratelimit.remaining) {
    return NextResponse.json(
      { error: "Rate limit exceeded", resetAt: verification.ratelimit.reset },
      { status: 429 }
    );
  }

  return NextResponse.json({
    data: "protected resource",
    remainingCredits: verification.remaining,
  });
}

Temporary Keys

// app/api/keys/temporary/route.ts
import { unkey } from "@/lib/unkey";
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const { userId, durationHours } = await req.json();

  const expires = Date.now() + durationHours * 60 * 60 * 1000;

  const result = await unkey.keys.create({
    apiId: process.env.UNKEY_API_ID!,
    prefix: "tmp",
    ownerId: userId,
    expires,
    meta: {
      type: "temporary",
      purpose: "trial",
    },
    remaining: 500,
  });

  if (result.error) {
    return NextResponse.json({ error: result.error.message }, { status: 500 });
  }

  return NextResponse.json({
    key: result.result.key,
    expiresAt: new Date(expires).toISOString(),
  });
}

Standalone Rate Limiting (Without Keys)

// lib/ratelimit.ts
import { Ratelimit } from "@unkey/ratelimit";

const limiter = new Ratelimit({
  rootKey: process.env.UNKEY_ROOT_KEY!,
  namespace: "api.public",
  limit: 30,
  duration: "60s",
});

export async function checkRateLimit(identifier: string) {
  const result = await limiter.limit(identifier);

  return {
    success: result.success,
    remaining: result.remaining,
    reset: result.reset,
  };
}

Rate Limiting in Middleware

// middleware.ts
import { Ratelimit } from "@unkey/ratelimit";
import { NextRequest, NextResponse } from "next/server";

const limiter = new Ratelimit({
  rootKey: process.env.UNKEY_ROOT_KEY!,
  namespace: "middleware.global",
  limit: 60,
  duration: "60s",
});

export async function middleware(req: NextRequest) {
  const ip = req.headers.get("x-forwarded-for") ?? "127.0.0.1";

  const result = await limiter.limit(ip);

  if (!result.success) {
    return NextResponse.json(
      { error: "Too many requests" },
      {
        status: 429,
        headers: {
          "X-RateLimit-Limit": result.limit.toString(),
          "X-RateLimit-Remaining": result.remaining.toString(),
          "X-RateLimit-Reset": result.reset.toString(),
        },
      }
    );
  }

  const response = NextResponse.next();
  response.headers.set("X-RateLimit-Limit", result.limit.toString());
  response.headers.set("X-RateLimit-Remaining", result.remaining.toString());
  response.headers.set("X-RateLimit-Reset", result.reset.toString());

  return response;
}

export const config = {
  matcher: ["/api/:path*"],
};

Key Update and Revocation

// lib/key-management.ts
import { unkey } from "@/lib/unkey";

export async function updateKeyMeta(keyId: string, meta: Record<string, unknown>) {
  const result = await unkey.keys.update({
    keyId,
    meta,
  });
  return result;
}

export async function revokeKey(keyId: string) {
  const result = await unkey.keys.delete({
    keyId,
  });
  return result;
}

export async function updateKeyRateLimit(keyId: string, limit: number, refillRate: number) {
  const result = await unkey.keys.update({
    keyId,
    ratelimit: {
      type: "fast",
      limit,
      refillRate,
      refillInterval: 60000,
    },
  });
  return result;
}

Usage Tracking and Remaining Quota

// app/api/usage/route.ts
import { verifyApiKey } from "@/lib/verify-key";
import { NextRequest, NextResponse } from "next/server";

export async function GET(req: NextRequest) {
  const key = req.headers.get("authorization")?.slice(7);
  if (!key) {
    return NextResponse.json({ error: "Missing key" }, { status: 401 });
  }

  const verification = await verifyApiKey(key);
  if (!verification.valid) {
    return NextResponse.json({ error: "Invalid key" }, { status: 403 });
  }

  return NextResponse.json({
    remaining: verification.remaining,
    ratelimit: verification.ratelimit
      ? {
          limit: verification.ratelimit.limit,
          remaining: verification.ratelimit.remaining,
          reset: verification.ratelimit.reset,
        }
      : null,
    plan: (verification.meta as Record<string, string>)?.plan ?? "free",
  });
}

Multi-Namespace Rate Limiting

// lib/multi-ratelimit.ts
import { Ratelimit } from "@unkey/ratelimit";

const globalLimiter = new Ratelimit({
  rootKey: process.env.UNKEY_ROOT_KEY!,
  namespace: "global",
  limit: 100,
  duration: "60s",
});

const aiLimiter = new Ratelimit({
  rootKey: process.env.UNKEY_ROOT_KEY!,
  namespace: "ai.generation",
  limit: 5,
  duration: "60s",
});

export async function checkLimits(identifier: string, isAiRoute: boolean) {
  const globalResult = await globalLimiter.limit(identifier);
  if (!globalResult.success) {
    return { allowed: false, reason: "global_limit" };
  }

  if (isAiRoute) {
    const aiResult = await aiLimiter.limit(identifier);
    if (!aiResult.success) {
      return { allowed: false, reason: "ai_limit" };
    }
  }

  return { allowed: true };
}

Best Practices

  • Always use a prefix when creating keys (e.g., sk_, tmp_) so users and logs can identify key types at a glance.
  • Set remaining on keys to enforce hard usage caps independent of rate limits. Rate limits control speed; remaining controls total volume.
  • Use ownerId on every key so you can list and revoke all keys for a user when needed.
  • Store business logic in meta (plan tier, permissions) and read it on verification rather than maintaining a separate lookup table.
  • Return standard X-RateLimit-* headers so API consumers can implement proper backoff.
  • Use separate Ratelimit namespaces for different resource types (e.g., api.read vs. api.write) to apply distinct limits.
  • For temporary access (trials, previews), use expires and remaining together so the key is bounded in both time and usage.

Anti-Patterns

  • Storing the key plaintext in your own database. Unkey handles hashing. You only need the keyId for management operations; never persist the raw key after returning it to the user.
  • Verifying keys without checking result.valid. The API call can succeed (no error) but return valid: false for expired or revoked keys. Always check the boolean.
  • Using a single namespace for all rate limits. This conflates different resources and makes it impossible to tune limits independently.
  • Creating keys without ownerId. You lose the ability to query or revoke keys by user, making key management painful at scale.
  • Ignoring the remaining field. If you set a usage quota, you must communicate the remaining count to the user or they have no way to plan their consumption.
  • Hardcoding the root key in source. The root key has full account access. Keep it in environment variables and rotate it periodically.

Install this skill directly: skilldb add security-ratelimit-skills

Get CLI access →