npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

convex-api-tokens

v0.3.2

Published

Convex component for API token management: issuance, validation, rotation, revocation, and encrypted third-party key storage

Readme

convex-api-tokens

npm license

A Convex component for API token management — issuance, validation, rotation, revocation, and encrypted third-party key storage.

Built for SaaS apps that need to issue API keys to users, validate them on incoming requests, and securely store third-party credentials.

Convex Components Challenge — Issue #12: API Token Management

Features

  • Token Issuance — Generate sk_-prefixed tokens with namespaces, expiration, idle timeouts, and metadata
  • Token Validation — Validate tokens with detailed failure reasons (expired, idle_timeout, revoked, invalid)
  • Token Rotation — Refresh tokens while preserving metadata and audit trail
  • Bulk Revocation — Revoke by namespace, time range, or individual token
  • Encrypted Key Storage — AES-256-GCM encrypted storage for third-party API keys (Stripe, OpenAI, etc.)
  • Token Listing — Admin/dashboard queries for token management (never exposes raw tokens)
  • HTTP MiddlewarecreateTokenAuth() helper for protecting HTTP endpoints
  • Mutation MiddlewarewithTokenAuth() helper for protecting mutations with token auth
  • Automatic Cleanup — Public cleanup mutation for scheduled garbage collection
  • Invalidation CallbacksonInvalidate hook notifies your app when tokens are revoked
  • TypeScript Generics — Typed metadata with ApiTokens<MyMeta> for full type safety

Installation

npm install convex-api-tokens

Setup

1. Register the component

// convex/convex.config.ts
import { defineApp } from "convex/server";
import apiTokens from "convex-api-tokens/convex.config";

const app = defineApp();
app.use(apiTokens);

export default app;

2. Initialize the client

// convex/tokens.ts
import { ApiTokens } from "convex-api-tokens";
import { components } from "./_generated/api.js";

const apiTokens = new ApiTokens(components.apiTokens);

3. (Optional) Set encryption key for third-party key storage

In your Convex dashboard, set the environment variable API_TOKENS_ENCRYPTION_KEY, then pass it to the client:

const apiTokens = new ApiTokens(components.apiTokens, {
  API_TOKENS_ENCRYPTION_KEY: process.env.API_TOKENS_ENCRYPTION_KEY,
});

Usage

Create a token

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

export const createToken = mutation({
  args: { name: v.optional(v.string()) },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);

    const result = await apiTokens.create(ctx, {
      namespace: userId,
      name: args.name ?? "My API Key",
      metadata: { scopes: ["read", "write"] },
      expiresAt: Date.now() + 90 * 24 * 60 * 60 * 1000, // 90 days
      maxIdleMs: 30 * 24 * 60 * 60 * 1000, // 30 day idle timeout
    });

    // result.token is the raw key — show it once to the user
    // result.tokenPrefix is "sk_ab12...ef56" for display
    return result;
  },
});

Validate a token

export const validateToken = mutation({
  args: { token: v.string() },
  handler: async (ctx, args) => {
    const result = await apiTokens.validate(ctx, { token: args.token });

    if (!result.ok) {
      throw new Error(`Token invalid: ${result.reason}`);
      // reason: "expired" | "idle_timeout" | "revoked" | "invalid"
    }

    return { namespace: result.namespace, metadata: result.metadata };
  },
});

Rotate a token

export const rotateToken = mutation({
  args: { token: v.string() },
  handler: async (ctx, args) => {
    const result = await apiTokens.refresh(ctx, { token: args.token });

    if (!result.ok) throw new Error(result.reason);
    return result; // { token, tokenPrefix, tokenId }
  },
});

Revoke tokens

// Single token by raw value
await apiTokens.invalidate(ctx, { token: rawToken });

// Single token by ID (admin dashboard)
await apiTokens.invalidateById(ctx, { tokenId: "token_id_here" });

// All tokens for a user
await apiTokens.invalidateAll(ctx, { namespace: userId });

// All tokens created before a date
await apiTokens.invalidateAll(ctx, {
  namespace: userId,
  before: Date.now() - 90 * 24 * 60 * 60 * 1000,
});

List tokens (admin dashboard)

export const listTokens = query({
  args: {},
  handler: async (ctx) => {
    const userId = await getAuthUserId(ctx);
    return await apiTokens.list(ctx, {
      namespace: userId,
      includeRevoked: false,
    });
  },
});

Protect mutations with token auth

Use withTokenAuth() to automatically validate a token and pass auth info to your handler:

import { withTokenAuth } from "convex-api-tokens";
import { v } from "convex/values";

export const protectedMutation = mutation({
  args: { token: v.string(), data: v.string() },
  handler: withTokenAuth(apiTokens, async (ctx, args, auth) => {
    // auth = { namespace, metadata, tokenId }
    // Throws "Unauthorized: <reason>" if token is invalid
    return { saved: true, user: auth.namespace };
  }),
});

Schedule automatic cleanup

Call cleanup() from a scheduled function or cron to delete old revoked/expired tokens:

// In a cron job (convex/crons.ts)
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api.js";

const crons = cronJobs();
crons.daily("cleanup tokens", { hourUTC: 3, minuteUTC: 0 }, internal.tokens.cleanupExpired);
export default crons;

// In your tokens file
export const cleanupExpired = internalMutation({
  handler: async (ctx) => {
    const deleted = await apiTokens.cleanup(ctx, {
      olderThanMs: 30 * 24 * 60 * 60 * 1000, // 30 days
    });
    console.log(`Cleaned up ${deleted} expired tokens`);
  },
});

Invalidation callbacks

Get notified when tokens are revoked:

const apiTokens = new ApiTokens(components.apiTokens, {
  onInvalidate: async (info) => {
    console.log(`Token ${info.method}: namespace=${info.namespace}`);
    // info.method: "invalidate" | "invalidateById" | "invalidateAll"
    // info.tokenId: set for invalidateById
    // info.count: set for invalidateAll
    // info.namespace: set when available
  },
});

TypeScript generics for typed metadata

// Define your metadata type
type TokenMeta = {
  scopes: ("read" | "write" | "admin")[];
  orgId: string;
};

// Pass it as a generic
const apiTokens = new ApiTokens<TokenMeta>(components.apiTokens);

// Now metadata is typed everywhere
const result = await apiTokens.validate(ctx, { token });
if (result.ok) {
  result.metadata?.scopes; // ("read" | "write" | "admin")[]
  result.metadata?.orgId;  // string
}

const tokens = await apiTokens.list(ctx, { namespace: userId });
tokens[0].metadata?.scopes; // typed!

Store third-party API keys

The simplest way — server-side encryption in mutations/queries (no action needed):

// Initialize with encryption key (reads from process.env in app context)
const apiTokens = new ApiTokens(components.apiTokens, {
  API_TOKENS_ENCRYPTION_KEY: process.env.API_TOKENS_ENCRYPTION_KEY,
});

// Store a key — encrypted automatically server-side
export const storeApiKey = mutation({
  args: { keyName: v.string(), value: v.string() },
  handler: async (ctx, args) => {
    await apiTokens.storeKey(ctx, {
      namespace: "my_app",
      keyName: args.keyName,
      value: args.value,
    });
  },
});

// Retrieve a key — decrypted automatically server-side
export const getApiKey = query({
  args: { keyName: v.string() },
  handler: async (ctx, args) => {
    return await apiTokens.getKey(ctx, {
      namespace: "my_app",
      keyName: args.keyName,
    });
  },
});

You can also use the lower-level storeEncrypted/getEncryptedKey + encryptValue/decryptValue utilities for manual encryption in actions.

Protect HTTP endpoints

import { createTokenAuth } from "convex-api-tokens";

const withApiToken = createTokenAuth(components.apiTokens);

export const myEndpoint = httpAction(async (ctx, request) => {
  const auth = await withApiToken(ctx, request);
  if (!auth.ok) {
    return new Response(JSON.stringify({ error: auth.reason }), {
      status: 401,
      headers: { "Content-Type": "application/json" },
    });
  }
  // auth.namespace and auth.metadata available here
  return new Response(JSON.stringify({ user: auth.namespace }));
});

Namespace as user ID

Namespaces are strings that group tokens by owner. Convert non-string IDs:

// Convex user ID (already a string)
await apiTokens.create(ctx, { namespace: userId, ... });

// Numeric ID — convert to string
await apiTokens.create(ctx, { namespace: String(orgId), ... });

API Reference

ApiTokens<M> class

| Method | Context | Description | |--------|---------|-------------| | create(ctx, args) | mutation | Issue a new token | | validate(ctx, args) | mutation | Validate and touch a token | | touch(ctx, args) | mutation | Reset idle timeout | | refresh(ctx, args) | mutation | Rotate token, preserve metadata | | invalidate(ctx, args) | mutation | Revoke single token by value | | invalidateById(ctx, args) | mutation | Revoke by token ID | | invalidateAll(ctx, args) | mutation | Bulk revoke with filters | | list(ctx, args) | query | List tokens for namespace | | cleanup(ctx, args?) | mutation | Delete old revoked/expired tokens | | storeEncrypted(ctx, args) | mutation | Store encrypted third-party key | | getEncryptedKey(ctx, args) | query | Get encrypted key record | | deleteEncrypted(ctx, args) | mutation | Delete encrypted key | | listEncryptedKeys(ctx, args) | query | List key names for namespace |

Standalone helpers

| Function | Description | |----------|-------------| | createTokenAuth(component) | HTTP middleware — returns (ctx, request) => ValidateTokenResult | | withTokenAuth(apiTokens, handler) | Mutation middleware — validates token from args, passes auth to handler | | encryptValue(plaintext, secret) | AES-256-GCM encryption (for actions) | | decryptValue(encrypted, iv, secret) | AES-256-GCM decryption (for actions) |

Security

  • Tokens are SHA-256 hashed before storage — raw tokens are never persisted
  • Third-party keys use AES-256-GCM encryption with PBKDF2 key derivation (100,000 iterations)
  • Token prefixes (sk_ab12...ef56) are stored for display without exposing the full token
  • Component tables are isolated — your app code cannot accidentally access them
  • Random IVs ensure identical plaintexts produce different ciphertexts

Demo

See the example app for a complete working integration.

Author

Built and maintained by TimpiaAI.

License

MIT