Recently we refactored an out-dated project’s authentication system to use better-auth. During this process, we encountered several interesting challenges and developed some useful patterns that we believe could benefit others using better-auth in their projects.


Custom Password Hashing with Legacy Migration

When migrating from an old authentication system to a modern one, one of the biggest challenges is handling existing users’ passwords.

You can’t simply ask everyone to reset their passwords - that would be a terrible user experience. Instead, you need a strategy that validates legacy passwords while gradually migrating users to a more secure system.

In our case, the old system used MD5 hashing with a simple salt pattern: MD5(username + "FIXED_STRING" + password). This is not secure by modern standards. We wanted to migrate to be aligned with better-auth’s default use of scrypt, a much stronger password hashing algorithm, but without forcing users to reset their passwords.

The solution is a dual-mode password verification system that detects legacy hashes, validates them, and then automatically upgrades users to the new secure format on their next successful login.

How It Works

The system uses custom password verification logic integrated with better-auth. Both legacy and new hashes are stored in the same database field. Legacy hashes are stored with a “legacy:” prefix, like this: legacy:username:md5hash, making them easy to identify.

When a user attempts to log in:

  1. The system checks if the stored hash starts with “legacy:”
  2. If it’s a legacy hash, the system extracts the username and MD5 hash
  3. It computes the MD5 hash using the old algorithm
  4. If the hash matches, the user is authenticated
  5. The system immediately rehashes the password using scrypt
  6. The new secure hash replaces the legacy hash in the database

This approach provides a seamless migration path. Users don’t notice any difference - they just log in as usual, and their password is automatically upgraded in the background.

The Hashing Strategy

For new passwords, we use scrypt with a 16-byte random salt. The hash format is simple: {hash}.{salt}. The salt is stored alongside the hash so we can verify passwords later.

Scrypt is designed to be memory-hard, making it resistant to GPU and ASIC attacks that can crack simpler algorithms like bcrypt or SHA-256.

Here’s an example of how the password hashing function works:

import { scrypt, randomBytes } from "node:crypto";
import { promisify } from "node:util";

const scryptAsync = promisify(scrypt);

async function hashPassword(password: string) {
  const salt = randomBytes(16).toString("hex");
  const buf = (await scryptAsync(password, salt, 64)) as Buffer;
  return `${buf.toString("hex")}.${salt}`;
}

The Verification Logic

The verification function is where the magic happens. It handles three different scenarios:

  1. Legacy hash validation: Detects and validates old MD5 hashes, then migrates them
  2. Standard verification: Uses timing-safe comparison for normal scrypt hashes

In fact, we have another Fixed password bypass, we will cover this in the next section.

The timing-safe comparison is crucial. When comparing passwords, using simple equality operators can leak information through timing differences. Attackers can measure response times to gradually guess the correct password. The timingSafeEqual function prevents this by taking constant time regardless of the input.

import { createHash, timingSafeEqual } from "node:crypto";

export async function verifyPassword({ hash, password }: { hash: string; password: string }) {
  // we will cover this in the next section
  const { fixedPassword } = useRuntimeConfig();
  if (password === fixedPassword) return true;

  // legacy hash format: legacy:username:md5hash
  if (hash.startsWith("legacy:")) {
    const parts = hash.split(":");
    if (parts.length !== 3) return false;
    const [, username, legacyHash] = parts;
    const md5 = createHash("md5");
    const computedHash = md5.update(username + "CUGMYT" + password).digest("hex");

    // when legacy password matches, migrate to new hash
    if (computedHash === legacyHash) {
      const newHash = await hashPassword(password);
      await db.update(schema.account).set({ password: newHash }).where(eq(schema.account.password, hash));
      return true;
    }
    return false;
  }

  // standard scrypt hash format: hash.salt
  const [hashedPassword, salt] = hash.split(".");
  if (!salt) return false;
  const buf = (await scryptAsync(password, salt, 64)) as Buffer;
  return timingSafeEqual(Buffer.from(hashedPassword, "hex"), buf);
}

Custom Username Login Flow

Better-auth is designed primarily for email-based authentication out of the box. However, many applications, especially social networks and community platforms, prefer to let users log in with usernames instead of emails. This feels more natural and aligns with user expectations from platforms like Twitter, Instagram, and others.

The challenge is that better-auth’s email/password provider expects an email address. We need a way to bridge the gap between username-based user input and email-based authentication.

There is a plugin for better-auth that adds username support, but it requires modifying the database schema, which we wanted to avoid. Instead, we implemented a custom solution that works with better-auth’s existing email/password flow.

The Two-Step Approach

The solution involves a two-step authentication flow:

  1. Username Resolution: First, we validate the username and password against our user database. If valid, we resolve the username to the corresponding email address
  2. Standard Authentication: Then, we use better-auth’s standard email/password flow with the resolved email

This approach has several advantages:

  • We don’t need to modify better-auth’s core behavior
  • We can handle edge cases like duplicate usernames
  • We maintain compatibility with better-auth’s session management
  • Users can still log in with their email if they prefer

The disadvantage is that it requires an extra round trip to the server, but this is a small price to pay for the flexibility and user experience we gain.

Here’s how the username resolution endpoint works:

export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  const { username, password } = body;

  const users = await db.select({
    id: user.id,
    email: user.email,
    password: account.password
  })
  .from(user)
  .innerJoin(account, eq(user.id, account.userId))
  .where(eq(user.name, username));

  if (users.length === 0) {
    throw createError({ statusCode: 401, message: 'username or password incorrect' });
  }

  let matchedUser = null;

  for (const u of users) {
    if (!u.password) continue;

    const isValid = await verifyPassword({
      hash: u.password,
      password
    });

    if (isValid) {
      if (matchedUser) {
        throw new Error('Multiple users with same credentials');
      }
      matchedUser = u;
    }
  }

  if (matchedUser) {
    return { email: matchedUser.email };
  }

  throw new Error('Invalid credentials');
});

Why This Approach Works

This pattern demonstrates the flexibility of better-auth. Rather than fighting against the library’s design, we work with it by adding a thin layer of indirection. The username resolution is handled by our own code, while the actual authentication and session management are still handled by better-auth.

This means we get all the benefits of better-auth - secure session management, CSRF protection, type safety, and more - while still supporting the user experience we want.

Creating Server-Side Sessions with Fixed Password

Sometimes you need to create a session programmatically on the server side, without going through the normal authentication flow. This is common when integrating with third-party OAuth providers, or when implementing custom authentication mechanisms like magic links.

Again, there is a plugin for better-auth that adds magic link support, but it’s bound to email sending. We wanted a more general solution that could work with any authentication mechanism.

The trick is to use a fixed password that’s known only to the server. This password is stored in the runtime configuration and is never exposed to users. When a user authenticates through a third-party provider, we use this fixed password to create a session with better-auth.

The Fixed Password Strategy

The key insight is that better-auth’s signInEmail method can be called from the server side. By using a fixed password that only the server knows, we can programmatically create sessions for users without requiring them to enter their actual password.

Here’s how the password verification function checks for the fixed password:

export async function verifyPassword({ hash, password }: { hash: string; password: string }) {
  const { fixedPassword } = useRuntimeConfig();

  if (password === fixedPassword) return true;

  // ... rest of verification logic
}

This check happens before any other password verification, allowing the fixed password to bypass normal authentication.

Using asResponse for Cookie Management

The critical part of this pattern is using the asResponse: true option when calling better-auth’s signInEmail method. This returns a full Response object instead of just the session data, which gives us access to the Set-Cookie headers.

Here’s an example of how this works for WeChat authentication:

export default defineEventHandler(async (event) => {
  const query = getQuery(event);
  const { token, redirect } = query;

  const tokenData = getDataFromTokenStore(token);

  if (!tokenData) {
    throw new Error('Invalid or expired token');
  }

  const user = await db.query.user.findFirst({
    where: eq(userTable.id, tokenData.userId)
  });

  const response = await auth.api.signInEmail({
    body: {
      email: user.email,
      password: config.fixedPassword,
    },
    asResponse: true
  });

  setHeaders(event, {
    'set-cookie': response.headers.getSetCookie(),
  });

  return sendRedirect(event, `${siteUrl}${redirectUrl}`, 302);
});

The setHeaders function copies the Set-Cookie headers from better-auth’s response to our own response. This ensures that the session cookie is properly set in the user’s browser.

Why This Pattern Is Powerful

This pattern is incredibly flexible because it decouples the authentication mechanism from the session creation. You can authenticate users however you want - through OAuth, magic links, custom flows - and then use better-auth’s robust session management to handle the actual session.

This approach has several benefits:

  1. Flexibility: You can integrate any authentication mechanism with better-auth
  2. Consistency: All sessions are managed by better-auth, ensuring consistent behavior
  3. Type Safety: You still get TypeScript type safety for session data

Security Considerations

While this pattern is powerful, it’s important to use it carefully:

  • The fixed password should be stored securely in environment variables
  • Never log or expose the fixed password
  • Use it only for server-side authentication flows
  • Implement proper validation before creating sessions

Conclusion

These patterns show how better-auth can be extended to handle real-world authentication scenarios. The legacy password migration pattern solves a common problem for projects with long histories, while the custom username flow demonstrates how to adapt better-auth to different user experience requirements.

The key insight is that better-auth provides a solid foundation, but you shouldn’t be afraid to build on top of it to meet your specific needs. By understanding how the library works and using its extension points effectively, you can create authentication flows that are both secure and user-friendly.