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

better-auth-invitation-only

v1.0.0

Published

Invite-only registration plugin for Better Auth. Gate signups with admin-managed invitation codes, support email + OAuth flows, with full CRUD admin endpoints.

Readme

better-auth-invitation-only

npm version CI License: MIT TypeScript

Your app is not a nightclub, but it should have a bouncer. Invite-only registration plugin for Better Auth v1.5+ -- because "open signups" is just another way of saying "please, bots, come ruin everything."

Features

Not another "it just works" feature list written by someone who's never shipped anything. This one actually does what it says.

  • Invite-gated registration -- block all signups unless a valid invite code is provided
  • Email + OAuth support -- works with email/password signup and social OAuth (Google, GitHub, etc.)
  • Multi-use codes -- create shareable invite links with configurable max uses (1-10,000)
  • Batch invitations -- create up to 50 invitations in a single API call
  • Admin CRUD endpoints -- create, list, revoke, resend, and delete invitations via API
  • Domain whitelist -- restrict signups to specific email domains (supports *.example.com wildcards)
  • Custom metadata -- attach arbitrary data to invitations (team, role, department)
  • Lifecycle callback -- onInvitationUsed fires after signup for post-registration logic
  • Runtime toggle -- enable/disable invite-only mode without rebuilding
  • Configurable rate limits -- override default rate limits per endpoint
  • Cursor-paginated listing -- efficiently browse invitations with status filtering
  • Stats endpoint -- aggregate counts (pending, used, expired, revoked)
  • Code validation endpoint -- public endpoint to check code validity before signup
  • SHA-256 code hashing -- invite codes are never stored in plaintext
  • Auto-consumption -- invitation is automatically marked as used after successful signup
  • Soft revocation -- revoke invitations while preserving audit trail
  • Hard delete -- permanently remove invitation records (GDPR compliance)
  • Email callback -- pluggable email sending (bring your own Resend/Postmark/SES/etc.)
  • Rate limiting -- built-in per-endpoint rate limits (configurable)
  • Pluggable invite store -- swap the default in-memory store for Redis, KV, or any custom backend
  • Web Crypto fallback -- works in edge runtimes where node:crypto isn't available
  • Full type safety -- typed client plugin with $InferServerPlugin

Installation

One command. No 47-step Medium article required.

npm install better-auth-invitation-only

Quick Start

Server

Drop this into your auth config and suddenly you have standards.

import { betterAuth } from "better-auth";
import { inviteOnly } from "better-auth-invitation-only";

export const auth = betterAuth({
  // ... your config
  plugins: [
    inviteOnly({
      enabled: true,
      expiresInSeconds: 7 * 24 * 60 * 60, // 7 days
      sendInviteEmail: async ({ email, inviteUrl, code }) => {
        // Use your email service (Resend, Postmark, SES, etc.)
        await sendEmail({
          to: email,
          subject: "You're invited!",
          body: `Join here: ${inviteUrl}`,
        });
      },
      // Optional: restrict to specific domains (wildcards supported)
      allowedDomains: ["company.com", "*.partner.org"],
      // Optional: custom store for multi-process/serverless
      // inviteStore: new RedisInviteStore(redis),
      // Optional: post-signup callback
      onInvitationUsed: async ({ invitation, user }) => {
        await assignRole(user.id, invitation.metadata?.role);
      },
    }),
  ],
});

Client

The client side. Where hopes and dreams meet async/await.

import { createAuthClient } from "better-auth/client";
import { inviteOnlyClient } from "better-auth-invitation-only/client";

export const authClient = createAuthClient({
  plugins: [inviteOnlyClient()],
});

// Sign up with invite code
await authClient.signUp.email({
  email: "[email protected]",
  password: "secret",
  name: "User",
  inviteCode: "abc123def456...",
});

// OAuth: set cookie before redirect
authClient.inviteOnly.setInviteCodeCookie("abc123def456...");
await authClient.signIn.social({
  provider: "google",
  callbackURL: "/dashboard",
});

// Admin: create invitation
const { data } = await authClient.inviteOnly.createInvitation({
  email: "[email protected]",
  sendEmail: true,
  maxUses: 10, // multi-use code
  metadata: { team: "engineering", role: "member" },
});

// Admin: batch create
const { data: batch } = await authClient.inviteOnly.createBatchInvitations({
  invitations: [
    { email: "[email protected]", sendEmail: true },
    { email: "[email protected]", sendEmail: true, maxUses: 5 },
  ],
});

// Admin: list invitations
const { data: list } = await authClient.inviteOnly.listInvitations({
  status: "pending",
});

// Admin: delete invitation (hard delete)
await authClient.inviteOnly.deleteInvitation({ id: "inv-123" });

// Public: validate code
const { data: check } = await authClient.inviteOnly.validateInviteCode({
  code: "abc123",
});

// Public: check if invite-only is enabled
const { data: config } = await authClient.inviteOnly.getInviteConfig();

Configuration

See docs/configuration.md for all options.

API Reference

See docs/api-reference.md for all endpoints.

How It Works

Six steps. Fewer than your morning standup, and considerably more useful.

  1. Admin creates an invitation via /invite-only/create -- generates a unique code, stores SHA-256 hash
  2. User receives invite link: /register?invite=CODE
  3. On signup, the plugin's before-hook validates the code against the database
  4. After successful user creation, the after-hook marks the invitation as consumed
  5. For OAuth flows, the invite code is stored in a short-lived cookie before the redirect
  6. Multi-use codes track useCount and are fully consumed when the limit is reached

Database Schema

The plugin creates an invitation table. Yes, it touches your database. No, it won't text your ex.

| Column | Type | Description | |--------|------|-------------| | id | string | Primary key | | email | string | Invitee email | | codeHash | string | SHA-256 hash of invite code (unique, not returned in API) | | invitedBy | string | Admin user ID (FK to user) | | maxUses | number | Maximum number of times this code can be used (default: 1) | | useCount | number | Number of times this code has been used (default: 0) | | usedBy | string? | Last user who consumed it (FK to user) | | usedAt | date? | When fully consumed | | revokedAt | date? | Soft-delete timestamp | | expiresAt | date | Expiry timestamp | | createdAt | date | Creation timestamp | | metadata | string? | JSON string of custom metadata |

Run Better Auth's migration CLI or manage the table manually with your ORM.

Compatibility

Works with any framework and database that Better Auth supports. We tested it so you don't have to -- although you probably should anyway, because trust issues are healthy in software engineering.

Frameworks

The plugin registers endpoints and hooks through Better Auth's plugin API. It's framework-agnostic -- if Better Auth works with your framework, this plugin works too. Tested patterns include Next.js (App Router), Astro, Hono, Express, and TanStack Start.

Databases

Tested against real adapters:

| Database | Status | Notes | |----------|--------|-------| | SQLite (better-sqlite3) | Tested | Full integration test suite | | MongoDB | Tested | Full integration test suite via mongodb-memory-server | | PostgreSQL | Supported | Uses standard adapter operations | | MySQL | Supported | Uses standard adapter operations |

The plugin uses only standard adapter methods (findOne, findMany, create, update, delete, count). Community adapters (Convex, SurrealDB, PocketBase, etc.) should work if they implement these methods. If an adapter doesn't support count(), the plugin falls back to findMany + length with a performance warning.

Runtimes

| Runtime | Status | Notes | |---------|--------|-------| | Node.js >= 22 | Tested | Primary runtime | | Bun | Tested | Full test suite passes | | Cloudflare Workers / Edge | Partial | Code hashing works (Web Crypto fallback). Requires custom inviteStore -- the default in-memory store is stateless per invocation. |

Deployment Modes

| Mode | Default Store | Custom Store Needed? | |------|---------------|---------------------| | Single-process Node.js | In-memory Map | No | | PM2 cluster / multi-process | -- | Yes (Redis, database, etc.) | | Serverless (Vercel, AWS Lambda) | -- | Yes | | Edge (Cloudflare Workers) | -- | Yes (KV, D1, etc.) |

For multi-process or serverless, provide a custom inviteStore. See configuration.md.

Security

I take security seriously, which is a sentence that usually precedes a data breach announcement. In this case, though, I actually mean it.

  • Invite codes are hashed with SHA-256 before storage -- raw codes are never persisted
  • Email binding enforces that the signup email matches the invitation target
  • Domain whitelist restricts which email domains can use invitation codes
  • Public endpoints never expose PII (no email in validate response)
  • Pending invites map has TTL (5 min) and size cap (10K) to prevent memory abuse
  • OAuth cookies use Secure, SameSite=Lax, and short TTL
  • All inputs validated with Zod with length limits (max 256 chars)
  • Per-endpoint rate limiting prevents brute-force attacks

Error Handling

The plugin exports typed error codes if you need to handle specific failures. Each code is a { code, message } object -- because Better Auth 1.5 decided strings were too simple and honestly, they were right.

import { ERROR_CODES } from "better-auth-invitation-only";

// { code: "INVITE_REQUIRED", message: "Invitation code required" }
console.log(ERROR_CODES.INVITE_REQUIRED);

Full list: INVITE_REQUIRED, INVALID_INVITE, INVITE_EXPIRED, INVITE_EXHAUSTED, EMAIL_MISMATCH, ADMIN_REQUIRED, NOT_FOUND, ALREADY_USED, ALREADY_REVOKED, NO_LONGER_VALID, DOMAIN_NOT_ALLOWED, BATCH_EMPTY, EMAIL_NOT_CONFIGURED, EMAIL_SEND_FAILED, TOO_MANY_PENDING. See API Reference for the full table with HTTP status codes.

Known Limitations

Every project has them. Most just don't admit it.

  • In-memory store is single-process only -- the default MemoryInviteStore uses a process-local Map. In cluster mode or serverless, pending invite entries won't be shared between instances. Provide a custom inviteStore for distributed deployments.
  • OAuth invite flow requires cookie support -- the invite code is passed through a SameSite=Lax cookie. Safari ITP may block cookies in cross-domain OAuth redirects. Some frameworks (Next.js, SvelteKit, TanStack Start) need their cookie plugin configured for server-side cookie access.
  • adapter.count() with ne operator -- some adapter implementations handle { operator: "ne", value: null } inconsistently. The stats endpoint uses safeCount() which falls back to findMany + length if count() fails.
  • Cursor pagination tie-breaking -- if two invitations share an identical createdAt timestamp (within DB precision), cursor pagination may skip one. This is rare in practice.

License

MIT - Vibe Code