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

velvet-auth

v0.1.9

Published

<p align="center"> <img src="docs/banner.png?v=v0.1.9" alt="velvet-auth" width="100%" style="max-height:400px;object-fit:cover;" /> </p>

Readme


Why velvet-auth?

Every Bun/Elysia project needs the same auth stack: hash passwords securely, issue JWTs, rotate them, invalidate on logout. Setting it up from scratch every time is tedious and error-prone.

velvet-auth packages that stack into a single plugin. You bring your own database and email provider — velvet-auth handles the rest.


Features

  • JWT rotation — Access + refresh token rotation via httpOnly cookies, path-scoped for security
  • Native Argon2id — Password hashing via Bun.password, zero extra native dependencies
  • RESP-compatible sessions — Refresh tokens + JTI blacklist on logout using atomic GETDEL. Works with Redis, Valkey, KeyDB, Dragonfly, and Garnet
  • Adapter pattern — Plug in any database or email provider with a simple interface
  • Auth guardcreateAuthGuard() verifies the token and injects ctx.user on protected routes
  • Type-safe config — Full Zod validation with sane, secure defaults

Bun-only. Relies on Bun.password (native Argon2id) and Bun.RedisClient. No extra native dependencies required.


Requirements

  • Bun >= 1.0
  • Elysia >= 1.0
  • Redis >= 6 or any RESP-compatible server (Valkey, KeyDB, Dragonfly, Garnet)

Installation

bun add velvet-auth

Quick start

import { Elysia } from "elysia";
import { velvetAuth } from "velvet-auth";

// 1. Implement the UserStoreAdapter for your database
const userStore = {
  findById:       async (id) => db.users.findOne({ id }),
  findByUsername: async (username) => db.users.findOne({ username }),
  findByEmail:    async (email) => db.users.findOne({ email }),
  create:         async (data) => db.users.insert(data),
  updatePassword: async (id, hash) => db.users.update({ id }, { password: hash }),
  setEmailVerified: async (id) => db.users.update({ id }, { emailVerified: true }),
};

// 2. Implement the EmailAdapter for your email provider
const emailAdapter = {
  sendOtp:          async (to, otp) => mailer.send({ to, subject: "Your OTP", text: otp }),
  sendVerification: async (to, url) => mailer.send({ to, subject: "Verify your email", text: url }),
  checkStatus:      async () => true,
};

// 3. Mount the plugin
const app = new Elysia()
  .use(
    velvetAuth(userStore, emailAdapter, {
      jwt: {
        secret: process.env.JWT_SECRET!, // min 32 chars
      },
    }),
  )
  .listen(3000);

That's it. The following routes are now available:

| Method | Route | Description | |--------|-------|-------------| | POST | /auth/register | Create account | | POST | /auth/login | Login, set httpOnly cookies | | POST | /auth/logout | Logout + invalidate tokens | | POST | /auth/refresh | Rotate access + refresh tokens |


Protecting routes

Use createAuthGuard() to protect any route. It verifies the access token cookie, checks the JTI blacklist, and injects ctx.user.

import { Elysia } from "elysia";
import { velvetAuth, createAuthGuard } from "velvet-auth";

// Pass the same client to both velvetAuth and createAuthGuard to avoid opening two connections
const redis = new Bun.RedisClient(process.env.REDIS_URL!);
const config = { jwt: { secret: process.env.JWT_SECRET! }, redis: { client: redis } };

const authGuard = createAuthGuard(redis, config);

const app = new Elysia()
  .use(velvetAuth(userStore, emailAdapter, config))
  .use(authGuard)
  .get("/me", ({ user }) => user)         // { id, username, role, emailVerified }
  .get("/dashboard", ({ user }) => {
    return `Welcome, ${user.username}`;
  })
  .listen(3000);

Configuration

All options with their defaults:

velvetAuth(userStore, emailAdapter, {
  // Required
  jwt: {
    secret: string,        // min 32 chars
    expiresIn: "15m",
  },

  // Optional
  redis: {
    url: "redis://localhost:6379",  // used to create an internal client
    // client: myRedisClient,       // pass an existing Bun.RedisClient to reuse it
  },
  tokens: {
    accessTokenTtl: 900,   // 15 min (seconds)
    refreshTtl: 604800,    // 7 days  (seconds)
    verificationTtl: 86400,// 24h     (seconds)
    otpTtl: 900,           // 15 min  (seconds)
  },
  argon2: {
    memoryCost: 65536,
    timeCost: 3,
  },
  password: {
    minLength: 8,
    requireUppercase: true,
    requireNumber: true,
    requireSpecial: true,
  },
  prefix: "/auth",
  routes: {
    forgotPassword: true,
    emailVerification: true,
  },
});

Adapters

UserStoreAdapter

Implement this interface to connect velvet-auth to any database:

import type { UserStoreAdapter } from "velvet-auth";

const userStore: UserStoreAdapter = {
  findById:         async (id: string) => { /* return user or null */ },
  findByUsername:   async (username: string) => { /* return user or null */ },
  findByEmail:      async (email: string) => { /* return user or null */ },
  create:           async (data) => { /* persist and return created user */ },
  updatePassword:   async (id, hash) => { /* update password hash */ },
  setEmailVerified: async (id) => { /* mark email as verified */ },
};

The password field passed to create() is already hashed by velvet-auth — store it as-is.

EmailAdapter

Implement this interface to connect any email provider (Resend, Nodemailer, etc.):

import type { EmailAdapter } from "velvet-auth";

const emailAdapter: EmailAdapter = {
  sendOtp:          async (to: string, otp: string) => { /* send OTP email */ },
  sendVerification: async (to: string, url: string) => { /* send verification link */ },
  checkStatus:      async () => true, /* return false if provider is unreachable */
};

Types

// Minimum shape required from a user record
interface AuthUser {
  id: string;
  username: string;
  email: string;
  password: string;       // Argon2id hash
  role: string;
  emailVerified: boolean;
}

// Injected into ctx.user by createAuthGuard
interface AuthContext {
  id: string;
  username: string;
  role: string;
  emailVerified: boolean;
}

Security design

| Concern | Approach | |---------|----------| | Password hashing | Argon2id via Bun.password — native, no extra deps | | Email verification | SHA-256 of a random 32-byte token, stored in Redis | | Refresh tokens | UUID stored in Redis, consumed atomically with GETDEL | | JWT revocation | JTI blacklist in Redis on logout, TTL = accessTokenTtl | | Cookies | httpOnly, refresh token path-scoped to /auth/refresh | | Anti-enumeration | register returns a generic error for duplicate username/email |


Error responses

All errors follow the same shape:

{
  "error": "UNAUTHORIZED",
  "message": "Unauthorized"
}

| Code | Status | |------|--------| | UNAUTHORIZED | 401 | | FORBIDDEN | 403 | | NOT_FOUND | 404 | | BAD_REQUEST | 400 | | INTERNAL_SERVER_ERROR | 500 |


Roadmap

  • v0.1 — Core: register, login, logout, refresh, auth guard ✓
  • v0.2 — Email flows: forgot/reset password, email verification
  • v0.3 — RBAC: verifiedGuard, requiredRole, Drizzle adapter

License

MIT


If velvet-auth saves you time, a ⭐ on GitHub goes a long way.