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:
hgetallgives 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
undefinedfor 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
undefinedin your entry object - Redis will literally store “undefined” hmsetwants 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
SCANnotKEYS-KEYS *will block Redis in production (learned this the hard way) - Redis command arguments must be strings -
"100"not100 - 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
- Redis arguments must be strings:
COUNTneeds to be"100", not100 - No undefined in objects: Build your objects conditionally or Redis stores literal “undefined”
- SCAN result types: Bun gives you
[string, string[]]- need to type assert it - Use SCAN not KEYS:
KEYS *will murder your Redis server in production - 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:
- Runs on its own port (
:3000) that all my tools can talk to - Uses Redis for sessions - fast and reliable
- Spits out JWT tokens that my other apps can verify
- 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! 🚀