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
hgetallto 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
undefinedfor 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
undefinedvalues - Use
hmsetwith 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
SCANinstead ofKEYSfor production safety - Pass arguments as strings (e.g.,
"100"not100) - 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
- Redis command arguments must be strings:
COUNTshould be"100", not100 - Avoid undefined in entry objects: Build objects conditionally to prevent
[..., "expiry", undefined] - Type assertions for SCAN results: Bun returns
[string, string[]]for SCAN commands - Use SCAN not KEYS:
KEYS *blocks Redis in production - 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:
- Runs on a dedicated port (e.g.,
:3000) accessible to all my internal tools - Stores sessions in Redis for fast, reliable authentication
- Issues JWT tokens that my other applications can verify
- 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! 🚀