Building a Custom Redis Storage Adapter for OpenAuth with Bun


The Problem: Too Many Passwords, Too Many Tools

As a developer, I constantly build internal tools that I yak-shave into my workflow or dogfeed for daily use. Whether it’s a quick dashboard for monitoring, a personal task manager, or deployment tools, these applications pile up fast. The problem? Remembering unique passwords for each one becomes a nightmare.

Traditional approaches have their issues:

  • Separate auth for each app: Password fatigue and security risks
  • Shared passwords: Even worse security
  • Third-party auth providers: Overkill for internal tools, plus privacy concerns

The solution: A centralized authentication server using OpenAuth that all my internal tools can delegate to. One login, many applications.

Why OpenAuth as a Centralized Auth Server?

OpenAuth is perfect for this use case because it:

  • Acts as a standalone authentication service that other apps can trust
  • Supports multiple authentication providers (email codes, OAuth, etc.)
  • Provides JWT-based tokens that work across services
  • Is self-hostable - full control over my authentication data
  • Has a simple integration - just a few lines of code per app

But here’s the catch: when building authentication systems, choosing the right storage backend is crucial for performance and scalability. In this post, I’ll walk through how I created a custom Redis storage adapter for OpenAuth using Bun’s built-in Redis client.

Why Custom Redis Storage?

OpenAuth provides several built-in storage adapters (DynamoDB, Cloudflare KV, Memory), but what if you want to use Redis? For a centralized auth server serving multiple applications, Redis is ideal due to its:

  • Speed: In-memory data structure store
  • Built-in TTL: Automatic expiration of keys
  • Scalability: Easy to scale horizontally
  • Compatibility: Works with most cloud providers

The Challenge

OpenAuth requires storage adapters to implement the StorageAdapter interface:

interface StorageAdapter {
  get(key: string[]): Promise<any | undefined>;
  set(key: string[], value: any, expiry?: Date): Promise<void>;
  remove(key: string[]): Promise<void>;
  scan(prefix: string[]): AsyncGenerator<[string[], any]>;
}

Implementation

Here’s how I built a Redis storage adapter using Bun’s native RedisClient:

1. Setting Up the Adapter

import {
  joinKey,
  splitKey,
  type StorageAdapter,
} from "@openauthjs/openauth/storage/storage";
import { RedisClient } from "bun";

export interface BunRedisStorageOptions {
  url?: string;
  keyPrefix?: string;
  debug?: boolean;
}

export function BunRedisStorage(
  options: BunRedisStorageOptions = {},
): StorageAdapter {
  const redis = new RedisClient(
    options.url || process.env.REDIS_URL || "redis://localhost:6379",
  );
  const keyPrefix = options.keyPrefix || "auth:";
  const debug = options.debug || false;

  // ... implementation
}

2. Implementing Get

The get method retrieves values from Redis. I used Redis hashes to store both the value and expiry metadata:

async get(key: string[]) {
  const joined = joinKey(key);
  const fullKey = keyPrefix + joined;

  if (debug) console.log(`[Storage GET] ${fullKey}`);

  const entry = await redis.hgetall(fullKey);

  if (!entry || Object.keys(entry).length === 0) {
    if (debug) console.log(`[Storage GET] ${fullKey} - NOT FOUND`);
    return undefined;
  }

  // Parse stored values
  const value = entry.value ? JSON.parse(entry.value) : undefined;
  const expiry = entry.expiry ? parseInt(entry.expiry) : undefined;

  // Check if expired
  if (expiry && Date.now() >= expiry) {
    if (debug) console.log(`[Storage GET] ${fullKey} - EXPIRED`);
    await redis.del(fullKey);
    return undefined;
  }

  if (debug) console.log(`[Storage GET] ${fullKey} - FOUND`);
  return value;
}

Key decisions:

  • Use hgetall to retrieve hash data as an object
  • Store values as JSON strings for flexibility
  • Handle expiry in-code AND use Redis TTL for automatic cleanup
  • Return undefined for missing or expired keys

3. Implementing Set

The set method stores values with optional expiration:

async set(key: string[], value: any, expiry?: Date) {
  const joined = joinKey(key);
  const fullKey = keyPrefix + joined;

  if (debug) {
    console.log(`[Storage SET] ${fullKey}`,
      expiry ? `(expires: ${expiry.toISOString()})` : ''
    );
  }

  const entry: Record<string, string> = {
    value: JSON.stringify(value),
  };

  // Only add expiry if provided
  if (expiry) {
    entry.expiry = expiry.getTime().toString();
  }

  // Store as hash
  await redis.hmset(fullKey, Object.entries(entry).flat());

  // Set Redis TTL for automatic cleanup
  if (expiry) {
    const ttl = Math.max(
      Math.floor((expiry.getTime() - Date.now()) / 1000),
      1
    );
    await redis.expire(fullKey, ttl);
  }
}

Key decisions:

  • Build entry object conditionally to avoid undefined values
  • Use hmset with flattened entries array
  • Set Redis TTL as a backup cleanup mechanism
  • Ensure TTL is at least 1 second

4. Implementing Remove

Straightforward deletion:

async remove(key: string[]) {
  const joined = joinKey(key);
  const fullKey = keyPrefix + joined;
  await redis.del(fullKey);
}

5. Implementing Scan

The scan method is an async generator that yields all keys matching a prefix:

async *scan(prefix: string[]) {
  const prefixStr = joinKey(prefix);
  const fullPrefix = keyPrefix + prefixStr;
  const now = Date.now();

  let cursor = "0";
  do {
    // Use SCAN for cursor-based iteration
    const result = await redis.send("SCAN", [
      cursor,
      "MATCH",
      fullPrefix + "*",
      "COUNT",
      "100"
    ]) as [string, string[]];

    cursor = result[0];
    const keys = result[1];

    for (const key of keys) {
      const entry = await redis.hgetall(key);

      if (!entry || Object.keys(entry).length === 0) continue;

      // Skip expired entries
      const expiry = entry.expiry ? parseInt(entry.expiry) : undefined;
      if (expiry && now >= expiry) {
        await redis.del(key);
        continue;
      }

      const value = entry.value ? JSON.parse(entry.value) : undefined;
      if (value !== undefined) {
        // Remove key prefix and return
        const keyWithoutPrefix = key.substring(keyPrefix.length);
        yield [splitKey(keyWithoutPrefix), value];
      }
    }
  } while (cursor !== "0");
}

Key decisions:

  • Use Redis SCAN instead of KEYS for production safety
  • Pass arguments as strings (e.g., "100" not 100)
  • Clean up expired entries during scan
  • Properly remove prefix before yielding keys

Using the Adapter

import { issuer } from "@openauthjs/openauth";
import { BunRedisStorage } from "./storage";

const auth = issuer({
  storage: BunRedisStorage({
    url: process.env.REDIS_URL,
    keyPrefix: "auth:",
    debug: process.env.NODE_ENV === "development",
  }),
  // ... rest of config
});

Critical: Setting AUTH_SECRET

One gotcha I encountered: OpenAuth encrypts cookies using an encryption key. Without a persistent AUTH_SECRET environment variable, the server generates a new random key on each restart, breaking cookie decryption.

Solution: Always set AUTH_SECRET in your .env:

# Generate a secure random secret
openssl rand -base64 32

# Add to .env
AUTH_SECRET="your-generated-secret-here"

Common Pitfalls

  1. Redis command arguments must be strings: COUNT should be "100", not 100
  2. Avoid undefined in entry objects: Build objects conditionally to prevent [..., "expiry", undefined]
  3. Type assertions for SCAN results: Bun returns [string, string[]] for SCAN commands
  4. Use SCAN not KEYS: KEYS * blocks Redis in production
  5. Set AUTH_SECRET: Without it, cookies can’t be decrypted across restarts

Testing the Adapter

Here’s a simple test to verify functionality:

const storage = BunRedisStorage({ debug: true });

// Test set and get
await storage.set(["test", "key"], { data: "hello" });
const value = await storage.get(["test", "key"]);
console.log(value); // { data: "hello" }

// Test expiry
const expiry = new Date(Date.now() + 5000); // 5 seconds
await storage.set(["test", "expires"], { temp: true }, expiry);

// Test scan
for await (const [key, value] of storage.scan(["test"])) {
  console.log(key, value);
}

Conclusion

Building a custom storage adapter for OpenAuth is straightforward once you understand the interface requirements. The key is handling:

  • Proper serialization (JSON stringify/parse)
  • Expiry management (both in-code and Redis TTL)
  • Key prefixing and joining/splitting
  • Async generators for scanning

The complete implementation is production-ready and handles edge cases like expiration, missing keys, and cursor-based iteration.

My Centralized Auth Setup

With this Redis storage adapter, I now have a centralized OpenAuth server that:

  1. Runs on a dedicated port (e.g., :3000) accessible to all my internal tools
  2. Stores sessions in Redis for fast, reliable authentication
  3. Issues JWT tokens that my other applications can verify
  4. Uses email magic links - no passwords to remember!

Each of my internal tools simply redirects to this auth server when a user needs to log in, then receives a JWT token to verify the user’s identity. One auth server, zero password fatigue.

The workflow:

User visits internal tool →
Tool redirects to auth.internal.dev →
User enters email →
Receives magic link →
Auth server issues JWT →
Tool verifies JWT →
User is authenticated across all tools ✅

Resources:

Happy coding! 🚀