OpenAuth Redis Bun Authentication TypeScript

Devlog: Building a Redis Storage Adapter for OpenAuth with Bun

7 min read

My journey building a custom Redis storage adapter for OpenAuth using Bun's native Redis client to solve my internal tools authentication mess.

The Mess I Got Myself Into

So here’s the situation: I’ve been building internal tools like crazy. Dashboards, task managers, deployment scripts - you name it. And every single one needs authentication. The result? I’m drowning in passwords and login flows.

I tried the obvious solutions:

  • Separate auth for each app: Yeah, that lasted about 2 days before I started forgetting passwords
  • Shared passwords: Terrible idea, don’t do this
  • Third-party auth providers: Way too much complexity for my little internal tools

My brain finally clicked: What if I just build one auth server and make everything talk to it?

Enter OpenAuth. It’s perfect for this - standalone, self-hostable, and gives me JWT tokens I can use everywhere. But there’s a catch…

The Redis Problem

OpenAuth comes with storage adapters for DynamoDB, Cloudflare KV, and in-memory. But I wanted Redis. Why? Because:

  • It’s fast (in-memory, duh)
  • Has built-in TTL for automatic cleanup
  • Scales like a dream
  • I already run Redis everywhere anyway

So I rolled up my sleeves and started building a custom storage adapter.

First Look at the Interface

OpenAuth needs me to implement this:

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]>;
}

Simple enough, right? Famous last words.

Let’s Build This Thing

Getting Started with the Adapter

First things first, I need to set up the basic structure. Bun has a built-in Redis client, which is perfect:

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;

  // Now for the fun part...
}

Tackling the Get Method

First up: get. I decided to use Redis hashes so I could store both the value and expiry info together:

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 out the stored data
  const value = entry.value ? JSON.parse(entry.value) : undefined;
  const expiry = entry.expiry ? parseInt(entry.expiry) : undefined;

  // Check if it's 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;
}

What I learned:

  • hgetall gives me a nice object back
  • JSON.stringify/parse is my friend for storing complex data
  • I need to handle expiry both in code AND let Redis handle it with TTL
  • Always return undefined for missing stuff - that’s what OpenAuth expects

Next Up: The Set Method

Now for storing data. This one tripped me up a bit because I needed to handle optional expiry:

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 we actually have it
  if (expiry) {
    entry.expiry = expiry.getTime().toString();
  }

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

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

Gotchas I hit:

  • Don’t put undefined in your entry object - Redis will literally store “undefined”
  • hmset wants a flattened array, not nested objects
  • Always set a TTL backup - Redis is better at cleanup than my code
  • Make sure TTL is at least 1 second, Redis gets weird with 0

The Easy One: Remove

This one was actually straightforward:

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

Finally, something that just works! 🎉

The Beast: Scan Method

Okay, this one was the most complex. It’s an async generator that needs to find 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;

      // Clean up expired entries while we're at it
      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) {
        // Strip the prefix before returning
        const keyWithoutPrefix = key.substring(keyPrefix.length);
        yield [splitKey(keyWithoutPrefix), value];
      }
    }
  } while (cursor !== "0");
}

Things that bit me:

  • Use SCAN not KEYS - KEYS * will block Redis in production (learned this the hard way)
  • Redis command arguments must be strings - "100" not 100
  • Need to type assert the SCAN result as [string, string[]]
  • Clean up expired entries during scan - keeps things tidy
  • Remember to strip the key prefix before yielding

Putting It All Together

Here’s how I’m actually using this thing:

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 the config
});

The AUTH_SECRET Disaster

Okay, so I hit this weird bug where cookies would break every time I restarted the server. Turns out OpenAuth encrypts cookies with a secret key, and if you don’t set AUTH_SECRET, it generates a new random one each time. 🤦‍♂️

Fix this immediately:

# Generate a proper secret
openssl rand -base64 32

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

Things That Almost Broke Me

  1. Redis arguments must be strings: COUNT needs to be "100", not 100
  2. No undefined in objects: Build your objects conditionally or Redis stores literal “undefined”
  3. SCAN result types: Bun gives you [string, string[]] - need to type assert it
  4. Use SCAN not KEYS: KEYS * will murder your Redis server in production
  5. Set AUTH_SECRET: Seriously, don’t forget this one

Quick Test Drive

Here’s how I made sure it actually worked:

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

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

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

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

The Final Setup

So now I have this centralized auth server that:

  1. Runs on its own port (:3000) that all my tools can talk to
  2. Uses Redis for sessions - fast and reliable
  3. Spits out JWT tokens that my other apps can verify
  4. Uses magic email links - no more passwords!

The flow now looks like:

User visits tool →
Tool redirects to auth server →
User enters email →
Gets magic link →
Auth server gives JWT →
Tool verifies JWT →
User is in! ✅

No more password fatigue. One auth server to rule them all.

Useful links:

And that’s how I solved my authentication mess. Time to build more tools! 🚀