Catalog npm
PACKAGE EXPLORERView in registry

@00akshatsinha00/convex-api-keys

npm

A convex api keys component for Convex.

componentconvex
Versions collected1
Direct dependencies27From release 0.1.0
Last observed20 Oct, 21:54 UTC

README

Current package documentation

Convex API Keys

npm version

A native, zero-dependency Convex component for API key management. Handles key generation, SHA-256 hashing, verification, rate limiting, RBAC, usage credits, and audit logging -- all within Convex's transactional guarantees.

Found a bug? Feature request? File it here.

Why This Component?

Most API key solutions require external services, webhook plumbing, and separate databases. This component runs entirely inside Convex, giving you:

  • Transactional verification -- key lookup, rate limit check, credit decrement, and audit log write all happen in a single atomic mutation
  • Reactive dashboards -- use Convex queries to build real-time key usage dashboards that update automatically
  • Zero infrastructure -- no Redis for rate limiting, no separate analytics pipeline, no webhook endpoints to maintain
  • Component isolation -- all data lives in the component's private tables; your app tables stay clean

Features

  • Cryptographically secure key generation (configurable entropy, base62 encoded)
  • SHA-256 hashed storage (plaintext keys never persist)
  • Sliding window rate limiting with per-key and per-owner shared overrides
  • Usage credits with automatic refill (hourly/daily/weekly/monthly)
  • Role-based access control (RBAC) with permissions and roles
  • Key rotation with configurable grace periods
  • Full audit logging with action type filtering
  • Cursor-based pagination for key listings
  • Real-time reactive queries for dashboards
  • Analytics rollups (hourly + daily) with time-bucketed queries
  • Top keys by usage ranking
  • Multi-tenant support via namespaces and owner isolation
  • Configurable key bytes, log retention, and rollup intervals

Installation

npm install @00akshatsinha00/convex-api-keys

Register the component in your convex/convex.config.ts:

import { defineApp } from "convex/server";
import apiKeys from "@00akshatsinha00/convex-api-keys/convex.config";

const app = defineApp();
app.use(apiKeys, { name: "apiKeys" });
export default app;

Quick Start

Direct component access

import { mutation, query } from "./_generated/server";
import { components } from "./_generated/api";
import { v } from "convex/values";

export const createKey = mutation({
  args: { name: v.string() },
  handler: async (ctx, args) => {
    const userId = (await ctx.auth.getUserIdentity())!.subject;
    return await ctx.runMutation(components.apiKeys.lib.create, {
      ownerId: userId,
      name: args.name,
      namespace: "default",
    });
  },
});

export const verifyKey = mutation({
  args: { key: v.string() },
  handler: async (ctx, args) => {
    return await ctx.runMutation(components.apiKeys.lib.verify, {
      key: args.key,
    });
  },
});

Client SDK (class-based)

import { ApiKeys } from "@00akshatsinha00/convex-api-keys";
import { components } from "./_generated/api";

const apiKeys = new ApiKeys(components.apiKeys, {
  defaultNamespace: "production",
  defaultPrefix: "sk_live_",
  keyBytes: 32,              // configurable key entropy (default: 32)
  logRetentionDays: 90,      // auto-applied to purgeVerificationLogs
});

export const createKey = mutation({
  args: { name: v.string() },
  handler: async (ctx, args) => {
    const userId = (await ctx.auth.getUserIdentity())!.subject;
    return await apiKeys.create(ctx, { ownerId: userId, name: args.name });
  },
});

export const verifyKey = mutation({
  args: { key: v.string() },
  handler: async (ctx, args) => {
    const result = await apiKeys.verify(ctx, { key: args.key });
    // Static class methods for permission checking
    if (result.valid && ApiKeys.hasPermission(result, "write:data")) {
      // authorized
    }
    return result;
  },
});

Real-World Scenarios

Scenario 1: SaaS API with Tiered Plans

You run a SaaS product and want to issue API keys to customers on different pricing tiers. Free users get 100 requests/day, Pro users get 10,000, and Enterprise gets unlimited.

// convex/apiKeyManager.ts
import { ApiKeys } from "@00akshatsinha00/convex-api-keys";
import { components } from "./_generated/api";
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";

const apiKeys = new ApiKeys(components.apiKeys, {
  defaultNamespace: "api-v1",
  defaultPrefix: "sk_live_",
});

// Issue a key when a customer signs up or upgrades
export const issueKeyForPlan = mutation({
  args: {
    plan: v.union(v.literal("free"), v.literal("pro"), v.literal("enterprise")),
  },
  handler: async (ctx, args) => {
    const userId = (await ctx.auth.getUserIdentity())!.subject;

    const planConfig = {
      free:       { remaining: 100,   refill: { amount: 100,   interval: "daily" as const } },
      pro:        { remaining: 10000, refill: { amount: 10000, interval: "daily" as const } },
      enterprise: {},  // no usage cap
    };

    const config = planConfig[args.plan];

    return await apiKeys.create(ctx, {
      ownerId: userId,
      name: `${args.plan}-key`,
      meta: { plan: args.plan },
      ...config,
      ratelimit: { limit: 60, duration: 60000 },  // 60 req/min burst protection for all tiers
    });
  },
});

// Verify on every API request
export const handleApiRequest = mutation({
  args: { key: v.string(), endpoint: v.string() },
  handler: async (ctx, args) => {
    const result = await apiKeys.verify(ctx, {
      key: args.key,
      tags: { endpoint: args.endpoint },
    });

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

    return {
      authorized: true,
      remaining: result.remaining,  // undefined for Enterprise (unlimited)
      plan: result.meta?.plan,
    };
  },
});

// Dashboard query: show customer their usage
export const myKeyUsage = query({
  args: { keyId: v.string() },
  handler: async (ctx, args) => {
    return await apiKeys.getUsageStats(ctx, { keyId: args.keyId });
    // Returns: { total, valid, rateLimited, usageExceeded, expired, revoked, disabled, notFound }
  },
});

Scenario 2: Multi-Service RBAC (Microservice Gateway)

You have several internal services (billing, users, analytics) behind a gateway. Each API key should only access authorized services.

// convex/gateway.ts
import { ApiKeys, hasPermission, hasAllPermissions } from "@00akshatsinha00/convex-api-keys";
import { components } from "./_generated/api";
import { mutation } from "./_generated/server";
import { v } from "convex/values";

const apiKeys = new ApiKeys(components.apiKeys, {
  defaultNamespace: "gateway",
  defaultPrefix: "gw_",
});

// Admin: set up permissions and roles once
export const bootstrapRbac = mutation({
  handler: async (ctx) => {
    // Create granular permissions
    await apiKeys.createPermission(ctx, { name: "billing:read",  description: "View invoices" });
    await apiKeys.createPermission(ctx, { name: "billing:write", description: "Create charges" });
    await apiKeys.createPermission(ctx, { name: "users:read",    description: "List users" });
    await apiKeys.createPermission(ctx, { name: "users:write",   description: "Manage users" });
    await apiKeys.createPermission(ctx, { name: "analytics:read", description: "View metrics" });

    // Bundle permissions into roles
    await apiKeys.createRole(ctx, {
      name: "billing-admin",
      permissions: ["billing:read", "billing:write"],
    });
    await apiKeys.createRole(ctx, {
      name: "readonly",
      permissions: ["billing:read", "users:read", "analytics:read"],
    });
    await apiKeys.createRole(ctx, {
      name: "superadmin",
      permissions: ["billing:read", "billing:write", "users:read", "users:write", "analytics:read"],
    });
  },
});

// Issue a key with specific roles
export const issueServiceKey = mutation({
  args: { serviceName: v.string(), roles: v.array(v.string()) },
  handler: async (ctx, args) => {
    const result = await apiKeys.create(ctx, {
      ownerId: args.serviceName,
      name: `${args.serviceName}-key`,
    });

    // Assign roles to the newly created key
    await apiKeys.assignRoles(ctx, {
      keyId: result.keyId,
      roles: args.roles,
    });

    return result;
  },
});

// Gateway: verify and check permissions per endpoint
export const routeRequest = mutation({
  args: { key: v.string(), service: v.string(), action: v.string() },
  handler: async (ctx, args) => {
    const result = await apiKeys.verify(ctx, { key: args.key });

    if (!result.valid) {
      return { status: 401, error: result.code };
    }

    const requiredPermission = `${args.service}:${args.action}`;
    if (!hasPermission(result, requiredPermission)) {
      return { status: 403, error: "INSUFFICIENT_PERMISSIONS" };
    }

    return { status: 200, keyId: result.keyId, permissions: result.permissions };
  },
});

Scenario 3: Key Rotation for PCI/SOC2 Compliance

Your security policy requires rotating API keys every 90 days. You want zero-downtime rotation with a grace period so clients can migrate.

// convex/compliance.ts
import { ApiKeys, isKeyExpiringSoon, calculateExpiration } from "@00akshatsinha00/convex-api-keys";
import { components } from "./_generated/api";
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";

const apiKeys = new ApiKeys(components.apiKeys, {
  defaultNamespace: "production",
  defaultPrefix: "sk_live_",
});

// Create key with 90-day expiry
export const createComplianceKey = mutation({
  args: { name: v.string() },
  handler: async (ctx, args) => {
    const userId = (await ctx.auth.getUserIdentity())!.subject;
    return await apiKeys.create(ctx, {
      ownerId: userId,
      name: args.name,
      expires: calculateExpiration(90),  // 90 days from now
    });
  },
});

// Rotate a key with 48-hour grace period -- both old and new key work during grace
export const rotateKey = mutation({
  args: { keyId: v.string() },
  handler: async (ctx, args) => {
    const userId = (await ctx.auth.getUserIdentity())!.subject;

    const key = await apiKeys.getKey(ctx, { keyId: args.keyId });
    if (!key || key.ownerId !== userId) {
      throw new Error("Unauthorized");
    }

    // Old key stays valid for 48 hours; new key is returned immediately
    return await apiKeys.rotate(ctx, {
      keyId: args.keyId,
      gracePeriodMs: 48 * 60 * 60 * 1000,  // 48 hours
    });
  },
});

// Dashboard: show keys that need rotation soon
export const keysNeedingRotation = query({
  args: {},
  handler: async (ctx) => {
    const userId = (await ctx.auth.getUserIdentity())!.subject;
    const keys = await apiKeys.getKeysByOwner(ctx, { ownerId: userId });

    return keys.filter((k) => isKeyExpiringSoon(k.expires, 14));  // expiring within 14 days
  },
});

Scenario 4: Webhook Endpoint Protection

You expose a webhook URL that external services call. You want to rate limit incoming webhooks and track which integration sent each request.

// convex/webhooks.ts
import { ApiKeys } from "@00akshatsinha00/convex-api-keys";
import { components } from "./_generated/api";
import { httpAction, mutation } from "./_generated/server";
import { v } from "convex/values";

const apiKeys = new ApiKeys(components.apiKeys, {
  defaultNamespace: "webhooks",
  defaultPrefix: "whk_",
});

// Issue a webhook key for each integration partner
export const createWebhookKey = mutation({
  args: {
    partnerName: v.string(),
    maxPerMinute: v.number(),
  },
  handler: async (ctx, args) => {
    return await apiKeys.create(ctx, {
      ownerId: args.partnerName,
      name: `${args.partnerName}-webhook`,
      meta: { type: "webhook", partner: args.partnerName },
      ratelimit: { limit: args.maxPerMinute, duration: 60000 },
    });
  },
});

// HTTP action: receive webhook, verify key from header
export const receiveWebhook = httpAction(async (ctx, request) => {
  const authHeader = request.headers.get("Authorization");
  if (!authHeader?.startsWith("Bearer ")) {
    return new Response("Missing API key", { status: 401 });
  }

  const key = authHeader.slice(7);
  const result = await ctx.runMutation(components.apiKeys.lib.verify, {
    key,
    ip: request.headers.get("X-Forwarded-For") ?? undefined,
    tags: { source: "webhook", path: new URL(request.url).pathname },
  });

  if (!result.valid) {
    const status = result.code === "RATE_LIMITED" ? 429 : 401;
    return new Response(result.message, { status });
  }

  // Process the webhook payload...
  return new Response("OK", { status: 200 });
});

Scenario 5: Admin Dashboard with Analytics

Build a reactive admin panel showing API key health metrics, usage breakdowns, and audit trails.

// convex/adminDashboard.ts
import { ApiKeys } from "@00akshatsinha00/convex-api-keys";
import { components } from "./_generated/api";
import { query } from "./_generated/server";
import { v } from "convex/values";

const apiKeys = new ApiKeys(components.apiKeys, {
  defaultNamespace: "production",
});

// Overall health: total keys, active vs revoked, success rate
export const namespaceHealth = query({
  handler: async (ctx) => {
    return await apiKeys.getOverallStats(ctx, { namespace: "production" });
    // Returns: { totalKeys, activeKeys, disabledKeys, expiredKeys,
    //            revokedKeys, totalVerifications, successRate }
  },
});

// Per-owner usage breakdown (e.g., show each customer's consumption)
export const customerUsage = query({
  args: { ownerId: v.string() },
  handler: async (ctx, args) => {
    return await apiKeys.getUsageByOwner(ctx, {
      ownerId: args.ownerId,
      period: "day",
    });
  },
});

// Audit trail for compliance review
export const recentAuditEvents = query({
  args: { limit: v.optional(v.number()) },
  handler: async (ctx, args) => {
    return await apiKeys.getAuditLog(ctx, { limit: args.limit ?? 50 });
    // Returns: [{ action, actorId, targetKeyHash, timestamp, details }]
  },
});

// Verification history for a specific key (useful for debugging)
export const keyVerificationHistory = query({
  args: { keyId: v.string(), since: v.optional(v.number()) },
  handler: async (ctx, args) => {
    return await apiKeys.getVerificationLog(ctx, {
      keyId: args.keyId,
      since: args.since,
      limit: 100,
    });
    // Returns: [{ keyHash, timestamp, success, code, remaining, rateLimitRemaining, tags, ip }]
  },
});

Scenario 6: Scheduled Maintenance and Cleanup

Set up automatic cleanup of expired keys and old logs to keep your database lean.

// convex/crons.ts
import { cronJobs } from "convex/server";
import { components } from "./_generated/api";

const crons = cronJobs();

// Every hour: expire keys past their expiration date
crons.interval("expire stale keys", { hours: 1 }, components.apiKeys.lib.expireKeys, {
  namespace: "production",
});

// Every 6 hours: roll up raw verification logs into hourly analytics buckets
crons.interval("rollup analytics", { hours: 6 }, components.apiKeys.lib.rollupAnalytics, {
  namespace: "production",
  period: "hourly",
  olderThan: 6 * 60 * 60 * 1000,  // roll up logs older than 6 hours
});

// Daily: purge verification logs older than 30 days
crons.interval("cleanup old logs", { hours: 24 }, components.apiKeys.lib.cleanupLogs, {
  olderThanMs: 30 * 24 * 60 * 60 * 1000,
});

export default crons;

Unkey Integration (Optional)

The component works fully standalone with zero dependencies. Optionally, you can use Unkey as the key management engine while Convex provides reactive state, unlimited audit trails, and analytics rollups.

Why use Unkey mode?

  • Battle-tested key infrastructure -- Unkey handles key generation, verification, rate limiting at scale
  • Convex reactive layer -- real-time dashboards, subscriptions, and audit logging on top of Unkey
  • No webhooks needed -- Unkey has no event system; this component fills that gap with local logging
  • Same queries -- listKeys, getUsageStats, getAuditLog work identically in both modes

Installation

npm install @unkey/api

Setup

import { Unkey } from "@unkey/api";
import { UnkeyApiKeys } from "@00akshatsinha00/convex-api-keys/unkey";
import { components } from "./_generated/api";

// Construct the Unkey client in your app code (components can't access process.env)
const unkeyClient = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY! });

const apiKeys = new UnkeyApiKeys(
  components.apiKeys,
  { apiId: process.env.UNKEY_API_ID!, defaultNamespace: "production" },
  unkeyClient
);

Usage

Write operations (create, verify, revoke, update) must be called from actions since they make HTTP calls to Unkey:

import { action } from "./_generated/server";
import { v } from "convex/values";

export const createKey = action({
  args: { name: v.string() },
  handler: async (ctx, args) => {
    const userId = (await ctx.auth.getUserIdentity())!.subject;
    // 1. Calls Unkey API to generate key
    // 2. Mirrors result into component tables via mutation
    return await apiKeys.create(ctx, { ownerId: userId, name: args.name });
  },
});

export const verifyKey = action({
  args: { key: v.string() },
  handler: async (ctx, args) => {
    // 1. Calls Unkey API to verify
    // 2. Logs verification result into component tables
    return await apiKeys.verify(ctx, { key: args.key });
  },
});

Read operations work from queries (no Unkey call needed):

import { query } from "./_generated/server";

export const listKeys = query({
  handler: async (ctx) => {
    return await apiKeys.listKeys(ctx);
  },
});

Architecture: Unkey Mode

Your Convex App
  │
  ├── action: apiKeys.create(ctx, args)
  │     ├── 1. Unkey SDK → keys.create() → { key, keyId }
  │     └── 2. ctx.runMutation → component.lib.importKey()
  │           └── ctx.db.insert("keys", { unkeyKeyId, hash, ... })
  │
  ├── action: apiKeys.verify(ctx, args)
  │     ├── 1. Unkey SDK → keys.verifyKey() → { valid, code, remaining }
  │     └── 2. ctx.runMutation → component.lib.logExternalVerification()
  │           └── ctx.db.insert("verificationLogs", { ... })
  │
  └── query: apiKeys.listKeys(ctx)  ← pure Convex query, no Unkey call
        └── ctx.runQuery → component.lib.listKeys()

When to Use Which

FeatureNative ModeUnkey Mode
DependenciesZero@unkey/api
Key generationConvex (crypto.subtle)Unkey API
VerificationConvex mutation (atomic)Unkey API + local log
Rate limitingConvex sliding windowUnkey + local mirror
DashboardsReactive queriesReactive queries (same)
Audit trailFullFull
Write contextMutationAction (HTTP calls)
LatencySingle Convex callConvex + Unkey round-trip
Best forSelf-contained appsApps already using Unkey

API Reference

Key Lifecycle

MethodTypeDescription
create(ctx, args)mutationGenerate a new API key with optional credits, rate limit, RBAC, and keyBytes
verify(ctx, args)mutationVerify a key -- checks revoked, disabled, expired, grace, refill, credits, per-key + per-owner rate limit, RBAC
revoke(ctx, args)mutationRevoke a key (soft: true keeps record, soft: false hard-deletes)
update(ctx, args)mutationUpdate key properties (name, meta, expiry, credits, rate limit, enabled)
rotate(ctx, args)mutationGenerate a replacement key with optional grace period for the old key

create options

await apiKeys.create(ctx, {
  ownerId: "user_123",          // required: who owns this key
  name: "Production Key",       // display name (default: "Untitled Key")
  prefix: "sk_live_",           // key prefix for visual identification
  namespace: "production",      // logical grouping / environment
  meta: { plan: "pro" },        // arbitrary metadata returned on verify
  expires: Date.now() + 90 * 24 * 60 * 60 * 1000,  // optional expiration timestamp
  remaining: 10000,             // optional usage credit cap
  refill: { amount: 10000, interval: "daily" },     // auto-refill credits
  ratelimit: { limit: 100, duration: 60000 },       // 100 req / 60s sliding window
  roles: ["admin"],             // role names to assign
  permissions: ["keys:write"],  // direct permission names to assign
  environment: "staging",       // optional environment tag
  keyBytes: 32,                 // random bytes for key entropy (default: 32)
});
// Returns: { key: "sk_live_abc123...", keyId: "j57abc..." }

verify response

const result = await apiKeys.verify(ctx, { key, tags: { endpoint: "/api/data" }, ip: "1.2.3.4" });
FieldTypeDescription
validbooleanWhether the key passed all checks
codeOutcomeCode"VALID", "REVOKED", "EXPIRED", "RATE_LIMITED", "USAGE_EXCEEDED", "DISABLED", "NOT_FOUND", "ROTATION_GRACE_EXPIRED"
keyIdstring?Internal key document ID
ownerIdstring?Owner of the key
metaobject?Metadata stored on create
remainingnumber?Credits left (undefined if unlimited)
ratelimit{ remaining, reset }?Rate limit state (only present when rate limited)
permissionsstring[]Resolved permission names (direct + role-inherited)
rolesstring[]Resolved role names assigned to this key
messagestring?Human-readable status message

Queries

MethodTypeDescription
listKeys(ctx, args?)queryPaginated key listing by namespace/owner. Returns { keys, cursor?, hasMore }
getKey(ctx, { keyId })queryGet a single key by ID. Returns KeyInfo | null
getKeysByOwner(ctx, { ownerId })queryGet all keys for an owner across all namespaces

listKeys -- cursor-based pagination

// First page
const page1 = await apiKeys.listKeys(ctx, { namespace: "prod", limit: 25 });
// page1 = { keys: [...], cursor: "abc...", hasMore: true }

// Next page
const page2 = await apiKeys.listKeys(ctx, { namespace: "prod", limit: 25, cursor: page1.cursor });
// page2 = { keys: [...], cursor: undefined, hasMore: false }
ArgTypeDescription
namespacestring?Filter by namespace
ownerIdstring?Filter by owner
limitnumber?Page size (default: 100)
cursorstring?Cursor from previous listKeys result

RBAC

MethodTypeDescription
createPermission(ctx, args)mutationCreate a named permission (e.g., "billing:read")
createRole(ctx, args)mutationCreate a role bundling multiple permissions
assignRoles(ctx, args)mutationReplace a key's role assignments
assignPermissions(ctx, args)mutationReplace a key's direct permission assignments
listPermissions(ctx)queryList all registered permissions
listRoles(ctx)queryList all registered roles with their permissions
deletePermission(ctx, args)mutationDelete a permission by ID
deleteRole(ctx, args)mutationDelete a role by ID

Analytics

MethodTypeDescription
getUsageStats(ctx, args)queryPer-key verification breakdown by outcome code. Supports period: "hour" | "day" for rollup data
getUsageByOwner(ctx, args)queryAggregated stats across all of an owner's keys. Supports period param
getTopKeysByUsage(ctx, args)queryTop N keys by total verification count within a namespace (sorted descending)
getVerificationsOverTime(ctx, args)queryTime-bucketed verification data ({ timestamp, total, valid, failed }[]). Filter by key or namespace
getOverallStats(ctx, { namespace })queryNamespace-level health: total/active/disabled/expired/revoked keys, success rate
getAuditLog(ctx, args?)queryAudit trail filterable by keyId, actorId, actionType, since, and limit
getVerificationLog(ctx, args)queryVerification history for a specific key with since and limit

getTopKeysByUsage

const top = await apiKeys.getTopKeysByUsage(ctx, { namespace: "prod", limit: 10 });
// [{ keyHash, keyId, name, ownerId, total, valid }]

getVerificationsOverTime

const buckets = await apiKeys.getVerificationsOverTime(ctx, {
  namespace: "prod",    // or keyId for per-key data
  period: "hour",       // "hour" | "day"
  since: Date.now() - 7 * 24 * 60 * 60 * 1000,  // last 7 days
});
// [{ timestamp, total, valid, failed }]

getAuditLog filters

await apiKeys.getAuditLog(ctx, {
  keyId: "...",           // filter by key
  actorId: "user_123",   // filter by actor
  actionType: "key.created",  // filter by action type
  since: Date.now() - 86400000,  // events after this timestamp
  limit: 50,
});

Rate Limiting

MethodTypeDescription
checkRateLimit(ctx, args)mutationStandalone rate limit check (useful outside the verify flow)
setRateLimitOverride(ctx, args)mutationOverride a specific key's default rate limit (e.g., premium tier bump)
deleteRateLimitOverride(ctx, args)mutationRemove a key override, reverting to the key's default limit
setOwnerRateLimit(ctx, args)mutationSet a shared rate limit across ALL keys belonging to an owner
deleteOwnerRateLimit(ctx, args)mutationRemove the shared owner rate limit
getRateLimitOverrides(ctx, { namespace })queryList all overrides (key-level and owner-level) for a namespace

Per-owner shared rate limits

When any key owned by user X is verified, the shared owner limit for user X is decremented. This enforces a global request budget across all of an owner's keys.

// Set: user_123 can make 1000 total requests/hour across ALL their keys
await apiKeys.setOwnerRateLimit(ctx, {
  ownerId: "user_123",
  namespace: "production",
  limit: 1000,
  duration: 3600000,  // 1 hour
});

// Remove
await apiKeys.deleteOwnerRateLimit(ctx, {
  ownerId: "user_123",
  namespace: "production",
});

Admin / Maintenance

MethodTypeDescription
purgeExpiredKeys(ctx, args)mutationHard-delete expired keys older than a threshold
purgeVerificationLogs(ctx, args?)mutationDelete old verification logs (uses logRetentionDays from config)
expireKeysinternal mutationMark expired keys as disabled (cron: every 1 hour)
cleanupLogsinternal mutationRemove verification logs older than 90 days (cron: every 24 hours)
rollupAnalyticsinternal mutationAggregate raw logs into hourly buckets (cron: every 1 hour)
rollupDailyinternal mutationAggregate hourly rollups into daily buckets (cron: every 24 hours)

Helper Functions

Stateless utilities for working with verification results. Available as both standalone imports and static class methods:

// Standalone imports
import {
  hasPermission,       // hasPermission(result, "billing:read") → boolean
  hasAnyPermission,    // hasAnyPermission(result, ["a", "b"]) → boolean
  hasAllPermissions,   // hasAllPermissions(result, ["a", "b"]) → boolean
  hasRole,             // hasRole(result, "admin") → boolean
  isRateLimited,       // result.code === "RATE_LIMITED"
  isExpired,           // result.code === "EXPIRED"
  isRevoked,           // result.code === "REVOKED"
  formatKeyHint,       // "sk_live_...xyz1" -- safe for display in UIs
  calculateExpiration, // calculateExpiration(90) → timestamp 90 days from now
  isKeyExpiringSoon,   // true if key expires within N days (default 7)
} from "@00akshatsinha00/convex-api-keys";

// Or use as static class methods
ApiKeys.hasPermission(result, "billing:read");
ApiKeys.hasAnyPermission(result, ["billing:read", "billing:write"]);
ApiKeys.hasAllPermissions(result, ["billing:read", "billing:write"]);
ApiKeys.hasRole(result, "admin");
HelperSignatureDescription
hasPermission(result, permission) → booleanCheck for a single permission
hasAnyPermission(result, permissions[]) → booleanAt least one permission present
hasAllPermissions(result, permissions[]) → booleanAll permissions present
hasRole(result, role) → booleanCheck for a single role
isRateLimited(result) → booleancode === "RATE_LIMITED"
isExpired(result) → booleancode === "EXPIRED"
isRevoked(result) → booleancode === "REVOKED"
formatKeyHint(key) → stringRedact middle of key for safe display
calculateExpiration(days) → numberTimestamp N days from now
isKeyExpiringSoon(expiresAt, daysThreshold?) → booleanTrue if expiring within threshold

Architecture

System Overview

┌──────────────────────────────────────────────────────────┐
│                     Your Convex App                      │
│  ┌───────────┐  ┌──────────┐  ┌──────────────┐          │
│  │ Mutations  │  │ Queries  │  │   Actions    │          │
│  └─────┬─────┘  └────┬─────┘  └──────┬───────┘          │
│        │              │               │                  │
│        └──────┬───────┘     ┌─────────┘                  │
│     ApiKeys   │             │  UnkeyApiKeys (optional)   │
│     SDK       ▼             ▼                            │
│  ┌──────────────────────────────────────────────────┐    │
│  │           convex-api-keys Component              │    │
│  │  ┌──────┐ ┌────────┐ ┌──────┐ ┌────────┐        │    │
│  │  │ Keys │ │ Verify │ │ RBAC │ │Analytics│        │    │
│  │  └──┬───┘ └───┬────┘ └──┬───┘ └───┬────┘        │    │
│  │     │         │         │         │              │    │
│  │     └─────────┴─────────┴─────────┘              │    │
│  │                    │                             │    │
│  │     ┌──────────────┼──────────────┐              │    │
│  │     ▼              ▼              ▼              │    │
│  │  ┌──────┐   ┌────────────┐  ┌──────────┐        │    │
│  │  │ keys │   │ vLogs/audit│  │ rateLimits│        │    │
│  │  └──────┘   └────────────┘  └──────────┘        │    │
│  └──────────────────────────────────────────────────┘    │
│                                                          │
│  Optional: Actions ──HTTP──▶ Unkey API (external)        │
└──────────────────────────────────────────────────────────┘

Key Verification Flow

Every verify() call runs atomically in a single Convex mutation:

Client Request
      │
      ▼
┌─────────────┐    ┌──────────────┐
│ verify(key) │───▶│ SHA-256 Hash │
└─────────────┘    └──────┬───────┘
                          ▼
                   ┌──────────────┐
                   │  Lookup Key  │
                   │  (by hash)   │
                   └──────┬───────┘
                          ▼
              ┌───────────────────────┐
              │   Validation Chain    │
              │  1. revoked?          │
              │  2. disabled?         │
              │  3. expired?          │
              │  4. rotation grace?   │
              │  5. refill credits?   │
              │  6. credits left?     │
              │  7. per-key rate ok?  │
              │  8. owner rate ok?    │
              └───────────┬───────────┘
                          ▼
              ┌───────────────────────┐
              │  Resolve Permissions  │
              │  direct ∪ role perms  │
              └───────────┬───────────┘
                          ▼
              ┌───────────────────────┐
              │  Decrement Credits    │
              │  Log Verification     │
              │  Return Result        │
              └───────────────────────┘

If any check fails, the chain short-circuits with the appropriate code (e.g., RATE_LIMITED, USAGE_EXCEEDED). Credits are only decremented on successful verification.

Rate Limiting Flow

verify() called
      │
      ▼
┌─────────────────────────────────┐
│  Per-Key Rate Limit             │
│  ┌────────────────────────┐     │
│  │ 1. Check override for  │     │
│  │    this key's hash     │     │     rateLimitOverrides
│  │ 2. Fall back to key's  │◀────┼──── (by keyOrOwnerId)
│  │    default ratelimit   │     │
│  │ 3. checkAndUpdate      │     │     rateLimitBuckets
│  │    RateLimit(hash)     │────▶┼──── (sliding window)
│  └────────────────────────┘     │
│           │ pass                │
│           ▼                     │
│  Per-Owner Shared Rate Limit    │
│  ┌────────────────────────┐     │
│  │ 1. Check override for  │     │     rateLimitOverrides
│  │    this key's ownerId  │◀────┼──── (by keyOrOwnerId)
│  │ 2. If found, checkAnd  │     │
│  │    UpdateRateLimit     │     │     rateLimitBuckets
│  │    (ownerId)           │────▶┼──── (shared bucket)
│  └────────────────────────┘     │
│           │ pass                │
│           ▼                     │
│  Continue verification...       │
└─────────────────────────────────┘

Analytics Rollup Pipeline

Convex Scheduler
      │
      ├── Every 1 hour: rollupAnalytics
      │     │
      │     ├── Query verificationLogs WHERE timestamp ∈ [prevHourStart, currHourStart)
      │     ├── Group by keyHash → tally outcome codes
      │     └── Upsert into analyticsRollups (period="hour")
      │
      ├── Every 24 hours: rollupDaily
      │     │
      │     ├── Query analyticsRollups WHERE period="hour" AND timestamp ∈ [prevDayStart, currDayStart)
      │     ├── Group by keyHash → sum hourly buckets
      │     └── Upsert into analyticsRollups (period="day")
      │
      └── Every 24 hours: cleanupLogs
            │
            └── Delete verificationLogs WHERE timestamp < (now - 90 days)

Dashboard queries read from:
  ┌─────────────────────┐    ┌──────────────────────┐
  │ getUsageStats       │───▶│ analyticsRollups     │ (when period specified)
  │ getUsageByOwner     │    │ (pre-aggregated)     │
  │ getOverallStats     │    └──────────────────────┘
  │ getTopKeysByUsage   │
  │ getVerifications    │    ┌──────────────────────┐
  │   OverTime          │───▶│ verificationLogs     │ (raw, when no period)
  └─────────────────────┘    │ (recent data)        │
                             └──────────────────────┘

Data Model

┌──────────┐     ┌────────────┐     ┌─────────────────┐
│   keys   │────▶│   roles    │────▶│   permissions   │
│          │     │ (roleIds)  │     │ (permissionIds) │
│ hash     │     └────────────┘     └─────────────────┘
│ ownerId  │
│ namespace│     ┌──────────────────┐
│ ratelimit│────▶│rateLimitOverrides│
│ refill   │     │ per-key or       │
│ remaining│     │ per-owner limits │
└────┬─────┘     └──────────────────┘
     │
     │           ┌──────────────────┐
     └──────────▶│ verificationLogs │
                 │ (append-only)    │
                 └────────┬─────────┘
                          │  rollup
                          ▼
                 ┌──────────────────┐
                 │analyticsRollups  │
                 │ hourly + daily   │
                 └──────────────────┘

┌──────────────────┐
│ rateLimitBuckets │  (sliding window state)
└──────────────────┘

┌──────────┐
│ auditLog │  (all mutations logged)
└──────────┘

8 tables, all private to the component:

TablePurposeIndexes
keysAPI key records (hashed, never plaintext)by_hash, by_owner, by_namespace, by_expires, by_unkey_id
rateLimitBucketsSliding window counters per key or ownerby_key_namespace
verificationLogsEvery verify() attempt with outcomeby_key_time, by_time
analyticsRollupsPre-aggregated hourly and daily statsby_ns_period, by_key_period
permissionsNamed permission entitiesby_name
rolesNamed roles bundling permissionsby_name
rateLimitOverridesPer-key and per-owner rate limit overridesby_key_namespace
auditLogAll mutation operations with detailsby_time, by_key, by_actor

RBAC Model

┌─────────┐   assignRoles   ┌───────────┐   permissions[]   ┌──────────────┐
│   Key   │─────────────────▶│   Role    │──────────────────▶│  Permission  │
│         │                  │ "admin"   │                   │ "keys:write" │
│         │  assignPerms     │ "viewer"  │                   │ "keys:read"  │
│         │─────────────────▶└───────────┘                   └──────────────┘
│         │  (direct perms)          ▲
└─────────┘                          │
                              createRole(name,
                                permissions[])

verify() → resolvePermissions():
  directPerms ∪ (roles → flatMap(role.permissions))

Keys can receive permissions two ways:

  1. Direct assignment via assignPermissions -- useful for one-off grants
  2. Role inheritance via assignRoles -- each role bundles multiple permissions

On verify(), the component resolves both paths and returns the merged set.


Verification Outcome Codes

CodeMeaningTriggered when
VALIDKey is valid and authorizedAll checks pass
NOT_FOUNDNo key matches the provided valueHash not in database
REVOKEDKey has been revokedrevokedAt is set
DISABLEDKey is temporarily disabledenabled === false
EXPIREDKey has passed its expirationexpires < now
ROTATION_GRACE_EXPIREDOld key's grace period ended after rotationrotationGraceEnd < now
USAGE_EXCEEDEDUsage credits exhaustedremaining <= 0
RATE_LIMITEDToo many requests in the current windowPer-key or per-owner limit exceeded

Configuration

const apiKeys = new ApiKeys(components.apiKeys, {
  defaultNamespace: "production",
  defaultPrefix: "sk_live_",
  keyBytes: 32,
  logRetentionDays: 90,
});
OptionTypeDefaultDescription
defaultNamespacestringundefinedNamespace auto-applied to create/verify/listKeys when not specified
defaultPrefixstringundefinedKey prefix auto-applied to create when not specified
keyBytesnumber32Random bytes of entropy for key generation
logRetentionDaysnumber90Used by purgeVerificationLogs to compute the cutoff timestamp
rollupIntervalnumber3600000Rollup interval in ms (informational; cron runs hourly)

Scheduled Jobs (Crons)

The component registers these internal cron jobs automatically:

JobIntervalDescription
expire keys1 hourDisables keys past their expires timestamp
rollup analytics1 hourAggregates verification logs into hourly analyticsRollups
rollup daily24 hoursAggregates hourly rollups into daily analyticsRollups
cleanup logs24 hoursDeletes verification logs older than 90 days

Security Considerations

  • Keys are hashed with SHA-256 before storage. The plaintext key is only returned once on create / rotate and never persists.
  • Constant-time-ish lookup: keys are looked up by hash index, not compared character by character. Timing attacks on the hash lookup are mitigated by Convex's query planner.
  • Component isolation: all tables are private. Your app code cannot directly read the keys table -- access is strictly through the component's mutation/query API.
  • Audit trail: every create, revoke, update, rotate, assignRoles, assignPermissions, purge, and setRateLimitOverride is logged to the auditLog table with timestamps and details.

Exported Types

import type {
  VerificationResult,   // verify() return type
  CreateKeyResult,      // create() return type
  KeyInfo,              // getKey() / listKeys() item shape
  UsageStats,           // getUsageStats() return type
  OverallStats,         // getOverallStats() return type
  AuditEntry,           // getAuditLog() item shape
  VerificationEntry,    // getVerificationLog() item shape
  OutcomeCode,          // "VALID" | "REVOKED" | "EXPIRED" | ...
  ApiKeysConfig,        // Constructor config type
  RunMutationCtx,       // Context type for mutation methods
  RunQueryCtx,          // Context type for query methods
  RunActionCtx,         // Context type for action methods (Unkey mode)
} from "@00akshatsinha00/convex-api-keys";

// Unkey integration types (optional)
import type { UnkeyApiKeys, UnkeyConfig } from "@00akshatsinha00/convex-api-keys/unkey";

See more example usage in example.ts.


Development

npm i
npm run build
npm test

License

Apache-2.0

Downloads & popularity

Awaiting observations

No metric observations yet

Download counts and popularity will appear here as the collector receives them.

Versions 1

Collected releases
VersionPublishedDirect dependenciesOpen version
0.1.0Registry latest12 Feb 202627

Package advisories

0
No matching advisories collected

This describes our collected data; it is not a security assessment.

Repository

PENDING
akshatsinha0/convex-api-keys

Shared repository data · GitHub

License detected in repository
Not collected

Metadata history

Meaningful changes
Current collected state Current

Observed since 20 Oct, 21:54 UTC

No superseded metadata states collected. Unchanged observations do not create new revisions.

Record identity

package:089367719806505f5ca39691897c62077fc85cecd5f83da1ece1eb8b616cc63e