- Published on
Reverse Engineering Better Auth: Building a Password Compatibility Layer
Reverse Engineering Better Auth: Building a Password Compatibility Layer
How I reverse-engineered Better Auth's password hashing to build a framework-agnostic compatibility package, saving hours of migration headaches for developers moving between authentication systems.
The Migration That Broke Everything
I had just finished migrating my application from Better Auth to NestJS. The database migration was smooth, the API routes were working, and I was feeling pretty good about the whole process. Then I tried to log in.
Invalid credentials.
I tried again. Same error. I checked the database - the user existed. I checked the password hash - it was there. I checked my authentication code - it looked correct. But nothing worked.
This is when I realized I had a problem. A big one. All my existing users couldn't log in because their password hashes were incompatible with standard Node.js password hashing methods.
The Investigation: Why Nothing Worked
Better Auth uses a specific password hashing implementation that isn't immediately obvious from the documentation. When I tried to verify existing passwords using Node.js's built-in crypto.scrypt, nothing worked. I tried every combination I could think of:
// None of these worked!
scrypt(password, salt, 64)
scrypt(password, Buffer.from(salt, 'hex'), 64)
scrypt(password + SECRET, salt, 64)
scrypt(SECRET + password, salt, 64)
// ... and many more variations
Hours of debugging later, I discovered the issue: Better Auth uses @noble/hashes/scrypt, which is a pure JavaScript implementation with different behavior than Node.js's native scrypt. The parameters and salt handling are also specific.
Here's what I found after digging through Better Auth's source code:
- Algorithm:
@noble/hashes/scrypt(not Node.jscrypto.scrypt) - Parameters: N=16384, r=16, p=1, dkLen=64
- Format:
salt:hash(both hex-encoded) - Normalization: NFKC Unicode normalization
- Salt: 16 random bytes (32 hex characters)
The subtle differences matter:
@noble/hashes/scryptvscrypto.scrypt: Different implementations produce different hashes- Salt format: Better Auth uses hex-encoded strings, not Buffers
- Unicode normalization: Ensures passwords like "café" and "cafe\u0301" hash the same way
- Parameter values: Even small differences in N, r, or p values break compatibility
Building the Solution
After spending hours reverse-engineering Better Auth's implementation and testing various approaches, I had a working solution. But I realized this wasn't just my problem - anyone migrating from Better Auth would face the same challenge.
So I extracted the solution into a standalone, framework-agnostic npm package: @better-auth-compat/password.
The Core Implementation
The package implements Better Auth's exact password hashing algorithm:
import { PasswordService } from '@better-auth-compat/password';
const passwordService = new PasswordService();
// Hash a password (creates Better Auth compatible hash)
const hash = await passwordService.hashPassword('myPassword123!');
// Returns: "ff2260be96728d140332e138d81e9aea:f357208a8eb032d1759b4865b5d29aeab22ecf55138dfd5360301e2dc832c34547bb8cdf603004c875c966064a0e330c5cbc3dc27ac346fe09b6b91c644e1255"
// Verify a password (works with existing Better Auth hashes)
const isValid = await passwordService.verifyPassword('myPassword123!', hash);
// Returns: true
Key Technical Details
1. Uses @noble/hashes/scrypt (Not Node.js crypto)
The critical difference is using the same library Better Auth uses:
// Dynamic import to support both ESM and CommonJS
const { scrypt } = await import('@noble/hashes/scrypt.js');
2. Exact Scrypt Parameters
Getting the parameters right was crucial - even small differences break compatibility:
const SCRYPT_PARAMS = {
N: 16384, // CPU/memory cost (2^14)
r: 16, // Block size
p: 1, // Parallelization
dkLen: 64, // Key length in bytes
maxmem: 128 * 16384 * 16 * 2, // 64 MB memory limit
};
3. Unicode Normalization
This was one of the trickier parts to get right:
// Critical for password compatibility
const normalizedPassword = password.normalize('NFKC');
4. Constant-Time Comparison
Security matters, so I implemented constant-time comparison to prevent timing attacks:
// Prevents timing attacks
private constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let c = 0;
for (let i = 0; i < a.length; i++) {
c |= a[i] ^ b[i];
}
return c === 0;
}
Real-World Use Cases
1. Framework Migration
This was my original use case - migrating from Better Auth to NestJS:
// In your new NestJS backend
import { PasswordService } from '@better-auth-compat/password';
@Injectable()
export class AuthService {
private passwordService = new PasswordService();
async login(email: string, password: string) {
const user = await this.findUserByEmail(email);
// Verify password against existing Better Auth hash
const isValid = await this.passwordService.verifyPassword(
password,
user.passwordHash
);
if (isValid) {
// User can log in with their existing password!
return this.createSession(user);
}
throw new UnauthorizedException('Invalid credentials');
}
}
2. Multi-Framework Support
You can use Better Auth password hashing in frameworks that don't support Better Auth:
// Next.js API Route
import { PasswordService } from '@better-auth-compat/password';
export default async function handler(req, res) {
const passwordService = new PasswordService();
const hash = await passwordService.hashPassword(req.body.password);
// Store hash in database
}
3. Gradual Migration
Gradually migrate users while maintaining compatibility:
// New passwords use your new system
// Old passwords still work with Better Auth compatibility
const isValid = await passwordService.verifyPassword(password, oldHash);
if (isValid) {
// Migrate to new hash format on next password change
const newHash = await newPasswordService.hashPassword(password);
}
Security Considerations
The package maintains Better Auth's security standards:
✅ Constant-time comparison - Prevents timing attacks
✅ Cryptographically secure random salts - Each password gets a unique salt
✅ High scrypt cost parameters - N=16384 provides strong security
✅ Unicode normalization - Prevents encoding-based attacks
✅ No side effects - Library doesn't log errors or expose sensitive data
Package Features
- ✅ 100% Better Auth Compatible - Hashes created by Better Auth work perfectly
- ✅ Framework Agnostic - Works with any Node.js framework
- ✅ TypeScript Support - Full type definitions included
- ✅ Zero Dependencies - Only depends on
@noble/hashes - ✅ Dual Module Support - Works with both ESM and CommonJS
- ✅ Well Tested - 18 comprehensive tests covering all edge cases
Getting Started
Installation is simple:
npm install @better-auth-compat/password
Basic usage:
import { PasswordService } from '@better-auth-compat/password';
const passwordService = new PasswordService();
// Hash a new password
const hash = await passwordService.hashPassword('securePassword123!');
// Verify a password (works with Better Auth hashes)
const isValid = await passwordService.verifyPassword('securePassword123!', hash);
// Optional: Validate password strength
const validation = passwordService.validatePasswordStrength('weak');
if (!validation.isValid) {
console.log(validation.errors);
}
The Impact
This package solves a real problem for developers:
- No Password Resets Required - Users keep their existing passwords during migration
- Seamless Migration - Move between frameworks without breaking authentication
- Community Benefit - Others facing the same challenge can use this solution
- Maintainability - Centralized, well-tested implementation
Lessons Learned
Building this package taught me several important lessons:
1. Documentation Matters
Better Auth's password hashing wasn't well-documented, leading to hours of reverse engineering. This experience reinforced the importance of clear documentation for library authors.
2. Compatibility is Hard
Small implementation differences can break everything. What seems like a minor detail (like using hex-encoded strings vs Buffers) can completely break password verification.
3. Open Source Helps
Sharing solutions benefits the entire community. What started as solving my own problem became a package that could help others avoid the same frustration.
4. Testing is Critical
18 tests ensure compatibility across edge cases. Without comprehensive testing, I would have missed subtle bugs that would have broken password verification in production.
Future Plans
This is just the beginning. Future plans include:
- NestJS Integration Package - Full NestJS module with guards and decorators
- Drizzle ORM Adapters - Schema definitions and type-safe helpers
- Additional Framework Integrations - Express, Fastify, and more
Conclusion
Migrating between authentication systems shouldn't break user authentication. @better-auth-compat/password bridges the gap, allowing seamless migration from Better Auth to any Node.js framework while maintaining 100% password compatibility.
Whether you're migrating frameworks, building a custom auth system, or need Better Auth compatibility in a different environment, this package has you covered.
Try it today:
npm install @better-auth-compat/password
GitHub Repository: GM-MikeLe/better-auth-compat
npm Package: @better-auth-compat/password
Have you faced similar migration challenges? Share your experience or contribute to the project on GitHub!