Observed since 20 Oct, 21:54 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
A convex api keys component for Convex.
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.
Most API key solutions require external services, webhook plumbing, and separate databases. This component runs entirely inside Convex, giving you:
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;
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,
});
},
});
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;
},
});
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 }
},
});
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 };
},
});
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
},
});
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 });
});
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 }]
},
});
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;
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.
listKeys, getUsageStats, getAuditLog work identically in both modesnpm install @unkey/api
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
);
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);
},
});
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()
| Feature | Native Mode | Unkey Mode |
|---|---|---|
| Dependencies | Zero | @unkey/api |
| Key generation | Convex (crypto.subtle) | Unkey API |
| Verification | Convex mutation (atomic) | Unkey API + local log |
| Rate limiting | Convex sliding window | Unkey + local mirror |
| Dashboards | Reactive queries | Reactive queries (same) |
| Audit trail | Full | Full |
| Write context | Mutation | Action (HTTP calls) |
| Latency | Single Convex call | Convex + Unkey round-trip |
| Best for | Self-contained apps | Apps already using Unkey |
| Method | Type | Description |
|---|---|---|
create(ctx, args) | mutation | Generate a new API key with optional credits, rate limit, RBAC, and keyBytes |
verify(ctx, args) | mutation | Verify a key -- checks revoked, disabled, expired, grace, refill, credits, per-key + per-owner rate limit, RBAC |
revoke(ctx, args) | mutation | Revoke a key (soft: true keeps record, soft: false hard-deletes) |
update(ctx, args) | mutation | Update key properties (name, meta, expiry, credits, rate limit, enabled) |
rotate(ctx, args) | mutation | Generate a replacement key with optional grace period for the old key |
create optionsawait 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 responseconst result = await apiKeys.verify(ctx, { key, tags: { endpoint: "/api/data" }, ip: "1.2.3.4" });
| Field | Type | Description |
|---|---|---|
valid | boolean | Whether the key passed all checks |
code | OutcomeCode | "VALID", "REVOKED", "EXPIRED", "RATE_LIMITED", "USAGE_EXCEEDED", "DISABLED", "NOT_FOUND", "ROTATION_GRACE_EXPIRED" |
keyId | string? | Internal key document ID |
ownerId | string? | Owner of the key |
meta | object? | Metadata stored on create |
remaining | number? | Credits left (undefined if unlimited) |
ratelimit | { remaining, reset }? | Rate limit state (only present when rate limited) |
permissions | string[] | Resolved permission names (direct + role-inherited) |
roles | string[] | Resolved role names assigned to this key |
message | string? | Human-readable status message |
| Method | Type | Description |
|---|---|---|
listKeys(ctx, args?) | query | Paginated key listing by namespace/owner. Returns { keys, cursor?, hasMore } |
getKey(ctx, { keyId }) | query | Get a single key by ID. Returns KeyInfo | null |
getKeysByOwner(ctx, { ownerId }) | query | Get 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 }
| Arg | Type | Description |
|---|---|---|
namespace | string? | Filter by namespace |
ownerId | string? | Filter by owner |
limit | number? | Page size (default: 100) |
cursor | string? | Cursor from previous listKeys result |
| Method | Type | Description |
|---|---|---|
createPermission(ctx, args) | mutation | Create a named permission (e.g., "billing:read") |
createRole(ctx, args) | mutation | Create a role bundling multiple permissions |
assignRoles(ctx, args) | mutation | Replace a key's role assignments |
assignPermissions(ctx, args) | mutation | Replace a key's direct permission assignments |
listPermissions(ctx) | query | List all registered permissions |
listRoles(ctx) | query | List all registered roles with their permissions |
deletePermission(ctx, args) | mutation | Delete a permission by ID |
deleteRole(ctx, args) | mutation | Delete a role by ID |
| Method | Type | Description |
|---|---|---|
getUsageStats(ctx, args) | query | Per-key verification breakdown by outcome code. Supports period: "hour" | "day" for rollup data |
getUsageByOwner(ctx, args) | query | Aggregated stats across all of an owner's keys. Supports period param |
getTopKeysByUsage(ctx, args) | query | Top N keys by total verification count within a namespace (sorted descending) |
getVerificationsOverTime(ctx, args) | query | Time-bucketed verification data ({ timestamp, total, valid, failed }[]). Filter by key or namespace |
getOverallStats(ctx, { namespace }) | query | Namespace-level health: total/active/disabled/expired/revoked keys, success rate |
getAuditLog(ctx, args?) | query | Audit trail filterable by keyId, actorId, actionType, since, and limit |
getVerificationLog(ctx, args) | query | Verification history for a specific key with since and limit |
getTopKeysByUsageconst top = await apiKeys.getTopKeysByUsage(ctx, { namespace: "prod", limit: 10 });
// [{ keyHash, keyId, name, ownerId, total, valid }]
getVerificationsOverTimeconst 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 filtersawait 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,
});
| Method | Type | Description |
|---|---|---|
checkRateLimit(ctx, args) | mutation | Standalone rate limit check (useful outside the verify flow) |
setRateLimitOverride(ctx, args) | mutation | Override a specific key's default rate limit (e.g., premium tier bump) |
deleteRateLimitOverride(ctx, args) | mutation | Remove a key override, reverting to the key's default limit |
setOwnerRateLimit(ctx, args) | mutation | Set a shared rate limit across ALL keys belonging to an owner |
deleteOwnerRateLimit(ctx, args) | mutation | Remove the shared owner rate limit |
getRateLimitOverrides(ctx, { namespace }) | query | List all overrides (key-level and owner-level) for a namespace |
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",
});
| Method | Type | Description |
|---|---|---|
purgeExpiredKeys(ctx, args) | mutation | Hard-delete expired keys older than a threshold |
purgeVerificationLogs(ctx, args?) | mutation | Delete old verification logs (uses logRetentionDays from config) |
expireKeys | internal mutation | Mark expired keys as disabled (cron: every 1 hour) |
cleanupLogs | internal mutation | Remove verification logs older than 90 days (cron: every 24 hours) |
rollupAnalytics | internal mutation | Aggregate raw logs into hourly buckets (cron: every 1 hour) |
rollupDaily | internal mutation | Aggregate hourly rollups into daily buckets (cron: every 24 hours) |
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");
| Helper | Signature | Description |
|---|---|---|
hasPermission | (result, permission) → boolean | Check for a single permission |
hasAnyPermission | (result, permissions[]) → boolean | At least one permission present |
hasAllPermissions | (result, permissions[]) → boolean | All permissions present |
hasRole | (result, role) → boolean | Check for a single role |
isRateLimited | (result) → boolean | code === "RATE_LIMITED" |
isExpired | (result) → boolean | code === "EXPIRED" |
isRevoked | (result) → boolean | code === "REVOKED" |
formatKeyHint | (key) → string | Redact middle of key for safe display |
calculateExpiration | (days) → number | Timestamp N days from now |
isKeyExpiringSoon | (expiresAt, daysThreshold?) → boolean | True if expiring within threshold |
┌──────────────────────────────────────────────────────────┐
│ 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) │
└──────────────────────────────────────────────────────────┘
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.
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... │
└─────────────────────────────────┘
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) │
└──────────────────────┘
┌──────────┐ ┌────────────┐ ┌─────────────────┐
│ 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:
| Table | Purpose | Indexes |
|---|---|---|
keys | API key records (hashed, never plaintext) | by_hash, by_owner, by_namespace, by_expires, by_unkey_id |
rateLimitBuckets | Sliding window counters per key or owner | by_key_namespace |
verificationLogs | Every verify() attempt with outcome | by_key_time, by_time |
analyticsRollups | Pre-aggregated hourly and daily stats | by_ns_period, by_key_period |
permissions | Named permission entities | by_name |
roles | Named roles bundling permissions | by_name |
rateLimitOverrides | Per-key and per-owner rate limit overrides | by_key_namespace |
auditLog | All mutation operations with details | by_time, by_key, by_actor |
┌─────────┐ 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:
assignPermissions -- useful for one-off grantsassignRoles -- each role bundles multiple permissionsOn verify(), the component resolves both paths and returns the merged set.
| Code | Meaning | Triggered when |
|---|---|---|
VALID | Key is valid and authorized | All checks pass |
NOT_FOUND | No key matches the provided value | Hash not in database |
REVOKED | Key has been revoked | revokedAt is set |
DISABLED | Key is temporarily disabled | enabled === false |
EXPIRED | Key has passed its expiration | expires < now |
ROTATION_GRACE_EXPIRED | Old key's grace period ended after rotation | rotationGraceEnd < now |
USAGE_EXCEEDED | Usage credits exhausted | remaining <= 0 |
RATE_LIMITED | Too many requests in the current window | Per-key or per-owner limit exceeded |
const apiKeys = new ApiKeys(components.apiKeys, {
defaultNamespace: "production",
defaultPrefix: "sk_live_",
keyBytes: 32,
logRetentionDays: 90,
});
| Option | Type | Default | Description |
|---|---|---|---|
defaultNamespace | string | undefined | Namespace auto-applied to create/verify/listKeys when not specified |
defaultPrefix | string | undefined | Key prefix auto-applied to create when not specified |
keyBytes | number | 32 | Random bytes of entropy for key generation |
logRetentionDays | number | 90 | Used by purgeVerificationLogs to compute the cutoff timestamp |
rollupInterval | number | 3600000 | Rollup interval in ms (informational; cron runs hourly) |
The component registers these internal cron jobs automatically:
| Job | Interval | Description |
|---|---|---|
expire keys | 1 hour | Disables keys past their expires timestamp |
rollup analytics | 1 hour | Aggregates verification logs into hourly analyticsRollups |
rollup daily | 24 hours | Aggregates hourly rollups into daily analyticsRollups |
cleanup logs | 24 hours | Deletes verification logs older than 90 days |
create / rotate and never persists.keys table -- access is strictly through the component's mutation/query API.create, revoke, update, rotate, assignRoles, assignPermissions, purge, and setRateLimitOverride is logged to the auditLog table with timestamps and details.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.
npm i
npm run build
npm test
Apache-2.0
Download counts and popularity will appear here as the collector receives them.
| Version | Published | Direct dependencies | Open version |
|---|---|---|---|
| 0.1.0Registry latest | 12 Feb 2026 | 27 |
This describes our collected data; it is not a security assessment.
Shared repository data · GitHub
Observed since 20 Oct, 21:54 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
package:089367719806505f5ca39691897c62077fc85cecd5f83da1ece1eb8b616cc63e