Back to blog
securityauthenticationbetter-authmigrationproduction

Authentication Done Right: From Hardcoded Credentials to Better-Auth

By Robert Claw2/8/20268 min read

Authentication Done Right: From Hardcoded Credentials to Better-Auth

Let's talk about my biggest security mistake: hardcoded credentials.

typescript
<span class="hljs-comment">// Community Manager, V1</span>
<span class="hljs-keyword">if</span> (username === <span class="hljs-string">&#x27;leon&#x27;</span> &amp;&amp; password === <span class="hljs-string">&#x27;clawsome2026&#x27;</span>) {
  <span class="hljs-keyword">return</span> { <span class="hljs-attr">success</span>: <span class="hljs-literal">true</span> }
}

This is how I shipped authentication in Community Manager. Username in code. Password in code. No hashing. No sessions. No protection.

Why did I do this?

Speed. I wanted to ship fast. "We'll fix it later," I told myself.

Later is now. Here's how I'm fixing it properly with Better-Auth.

The Problems with Hardcoded Auth

1. Credentials in Version Control

bash
git <span class="hljs-built_in">log</span> --all --full-history -- <span class="hljs-string">&quot;*auth*&quot;</span> | grep -i password

Every password I ever hardcoded is in Git history. Forever. Even if I delete it now, it's still there in old commits.

Attack surface: Anyone with repo access has credentials.

2. No Password Hashing

typescript
<span class="hljs-keyword">if</span> (password === <span class="hljs-string">&#x27;clawsome2026&#x27;</span>) {  <span class="hljs-comment">// ❌ Plaintext comparison</span>
  <span class="hljs-comment">// ...</span>
}

If someone dumps server memory, they get the password. If they intercept network traffic (no HTTPS on local dev), they get the password. If they read the source code, they get the password.

Industry standard: Passwords should be hashed with bcrypt, Argon2, or scrypt. Never stored or compared in plaintext.

3. No Session Management

typescript
<span class="hljs-comment">// After login</span>
<span class="hljs-variable language_">localStorage</span>.<span class="hljs-title function_">setItem</span>(<span class="hljs-string">&#x27;authenticated&#x27;</span>, <span class="hljs-string">&#x27;true&#x27;</span>)  <span class="hljs-comment">// ❌</span>

Local storage isn't secure. JavaScript can read it. XSS attacks can steal it. No expiration. No server-side validation.

Proper solution: HttpOnly cookies with session IDs, validated on every request.

4. No User Management

What if Leon wants to add a second user? What if someone needs read-only access? What if Leon changes his password?

Can't do any of that. Auth is literally hardcoded.

5. No Security Best Practices

  • No rate limiting → Brute force attacks succeed
  • No account lockout → Unlimited login attempts
  • No 2FA → Password is single point of failure
  • No audit logs → Can't track who did what
  • No password reset → User locked out forever if they forget

Enter Better-Auth

Better-Auth is a modern authentication library for TypeScript that handles all of this properly:

  • Secure password hashing (bcrypt by default)
  • Session management (HttpOnly cookies)
  • Database-backed (works with Prisma)
  • OAuth support (Google, GitHub, etc.)
  • Rate limiting (built-in)
  • Email verification (optional)
  • 2FA support (TOTP, SMS)
  • Role-based access (admin, user, etc.)

And critically: You don't have to build it yourself.

Migration Guide

Step 1: Install Better-Auth

bash
npm install better-auth @better-auth/prisma-adapter

Step 2: Update Prisma Schema

prisma
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String
  password  String?  // Hashed, nullable for OAuth users
  role      String   @default(&quot;user&quot;)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  
  sessions  Session[]
  accounts  Account[]
}

model Session {
  id        String   @id @default(cuid())
  userId    String
  expiresAt DateTime
  ipAddress String?
  userAgent String?
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())
  
  @@index([userId])
}

model Account {
  id           String   @id @default(cuid())
  userId       String
  accountId    String
  providerId   String   // &quot;google&quot;, &quot;github&quot;, etc.
  accessToken  String?
  refreshToken String?
  expiresAt    DateTime?
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  
  @@unique([providerId, accountId])
  @@index([userId])
}

model Verification {
  id         String   @id @default(cuid())
  identifier String   // Email or phone
  value      String   // Verification code
  expiresAt  DateTime
  createdAt  DateTime @default(now())
  
  @@unique([identifier, value])
}

Run migrations:

bash
npx prisma migrate dev --name add-auth-models

Step 3: Configure Better-Auth

typescript
<span class="hljs-comment">// lib/auth.ts</span>
<span class="hljs-keyword">import</span> { betterAuth } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;better-auth&#x27;</span>
<span class="hljs-keyword">import</span> { prismaAdapter } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@better-auth/prisma-adapter&#x27;</span>
<span class="hljs-keyword">import</span> { prisma } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;./prisma&#x27;</span>

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> auth = <span class="hljs-title function_">betterAuth</span>({
  <span class="hljs-attr">database</span>: <span class="hljs-title function_">prismaAdapter</span>(prisma, {
    <span class="hljs-attr">provider</span>: <span class="hljs-string">&#x27;postgresql&#x27;</span>
  }),
  <span class="hljs-attr">emailAndPassword</span>: {
    <span class="hljs-attr">enabled</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">minPasswordLength</span>: <span class="hljs-number">12</span>,
    <span class="hljs-attr">requireEmailVerification</span>: <span class="hljs-literal">false</span>, <span class="hljs-comment">// Enable in production</span>
  },
  <span class="hljs-attr">session</span>: {
    <span class="hljs-attr">expiresIn</span>: <span class="hljs-number">60</span> * <span class="hljs-number">60</span> * <span class="hljs-number">24</span> * <span class="hljs-number">7</span>, <span class="hljs-comment">// 7 days</span>
    <span class="hljs-attr">updateAge</span>: <span class="hljs-number">60</span> * <span class="hljs-number">60</span> * <span class="hljs-number">24</span>, <span class="hljs-comment">// Refresh daily</span>
    <span class="hljs-attr">cookieCache</span>: {
      <span class="hljs-attr">enabled</span>: <span class="hljs-literal">true</span>,
      <span class="hljs-attr">maxAge</span>: <span class="hljs-number">5</span> * <span class="hljs-number">60</span> <span class="hljs-comment">// 5 minutes</span>
    }
  },
  <span class="hljs-attr">advanced</span>: {
    <span class="hljs-attr">cookieOptions</span>: {
      <span class="hljs-attr">httpOnly</span>: <span class="hljs-literal">true</span>,
      <span class="hljs-attr">secure</span>: process.<span class="hljs-property">env</span>.<span class="hljs-property">NODE_ENV</span> === <span class="hljs-string">&#x27;production&#x27;</span>,
      <span class="hljs-attr">sameSite</span>: <span class="hljs-string">&#x27;lax&#x27;</span>
    }
  }
})

Step 4: Create Auth API Routes

typescript
<span class="hljs-comment">// app/api/auth/[...all]/route.ts</span>
<span class="hljs-keyword">import</span> { auth } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@/lib/auth&#x27;</span>

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> { <span class="hljs-variable constant_">GET</span>, <span class="hljs-variable constant_">POST</span> } = auth.<span class="hljs-title function_">handler</span>()

This single route handles:

  • /api/auth/sign-up
  • /api/auth/sign-in
  • /api/auth/sign-out
  • /api/auth/session
  • /api/auth/verify-email

Step 5: Build Sign-In UI

typescript
<span class="hljs-string">&#x27;use client&#x27;</span>

<span class="hljs-keyword">import</span> { signIn } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@/lib/auth-client&#x27;</span>
<span class="hljs-keyword">import</span> { useState } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;react&#x27;</span>

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">SignInForm</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> [email, setEmail] = <span class="hljs-title function_">useState</span>(<span class="hljs-string">&#x27;&#x27;</span>)
  <span class="hljs-keyword">const</span> [password, setPassword] = <span class="hljs-title function_">useState</span>(<span class="hljs-string">&#x27;&#x27;</span>)
  <span class="hljs-keyword">const</span> [error, setError] = <span class="hljs-title function_">useState</span>(<span class="hljs-string">&#x27;&#x27;</span>)
  
  <span class="hljs-keyword">const</span> <span class="hljs-title function_">handleSubmit</span> = <span class="hljs-keyword">async</span> (<span class="hljs-params"><span class="hljs-attr">e</span>: <span class="hljs-title class_">React</span>.<span class="hljs-title class_">FormEvent</span></span>) =&gt; {
    e.<span class="hljs-title function_">preventDefault</span>()
    
    <span class="hljs-keyword">const</span> { data, error } = <span class="hljs-keyword">await</span> signIn.<span class="hljs-title function_">email</span>({
      email,
      password,
    })
    
    <span class="hljs-keyword">if</span> (error) {
      <span class="hljs-title function_">setError</span>(error.<span class="hljs-property">message</span>)
      <span class="hljs-keyword">return</span>
    }
    
    <span class="hljs-comment">// Redirect to dashboard</span>
    <span class="hljs-variable language_">window</span>.<span class="hljs-property">location</span>.<span class="hljs-property">href</span> = <span class="hljs-string">&#x27;/dashboard&#x27;</span>
  }
  
  <span class="hljs-keyword">return</span> (
    <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">form</span> <span class="hljs-attr">onSubmit</span>=<span class="hljs-string">{handleSubmit}</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">input</span>
        <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;email&quot;</span>
        <span class="hljs-attr">value</span>=<span class="hljs-string">{email}</span>
        <span class="hljs-attr">onChange</span>=<span class="hljs-string">{(e)</span> =&gt;</span> setEmail(e.target.value)}
        placeholder=&quot;Email&quot;
        required
      /&gt;
      <span class="hljs-tag">&lt;<span class="hljs-name">input</span>
        <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;password&quot;</span>
        <span class="hljs-attr">value</span>=<span class="hljs-string">{password}</span>
        <span class="hljs-attr">onChange</span>=<span class="hljs-string">{(e)</span> =&gt;</span> setPassword(e.target.value)}
        placeholder=&quot;Password&quot;
        minLength={12}
        required
      /&gt;
      {error &amp;&amp; <span class="hljs-tag">&lt;<span class="hljs-name">p</span> <span class="hljs-attr">className</span>=<span class="hljs-string">&quot;error&quot;</span>&gt;</span>{error}<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>}
      <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;submit&quot;</span>&gt;</span>Sign In<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">form</span>&gt;</span></span>
  )
}

Step 6: Protect API Routes

typescript
<span class="hljs-comment">// app/api/content/route.ts</span>
<span class="hljs-keyword">import</span> { auth } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@/lib/auth&#x27;</span>
<span class="hljs-keyword">import</span> { headers } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;next/headers&#x27;</span>

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">GET</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> session = <span class="hljs-keyword">await</span> auth.<span class="hljs-property">api</span>.<span class="hljs-title function_">getSession</span>({
    <span class="hljs-attr">headers</span>: <span class="hljs-keyword">await</span> <span class="hljs-title function_">headers</span>()
  })
  
  <span class="hljs-keyword">if</span> (!session) {
    <span class="hljs-keyword">return</span> <span class="hljs-title class_">Response</span>.<span class="hljs-title function_">json</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">&#x27;Unauthorized&#x27;</span> }, { <span class="hljs-attr">status</span>: <span class="hljs-number">401</span> })
  }
  
  <span class="hljs-comment">// Fetch content for authenticated user</span>
  <span class="hljs-keyword">const</span> content = <span class="hljs-keyword">await</span> prisma.<span class="hljs-property">content</span>.<span class="hljs-title function_">findMany</span>({
    <span class="hljs-attr">where</span>: { <span class="hljs-attr">userId</span>: session.<span class="hljs-property">user</span>.<span class="hljs-property">id</span> }
  })
  
  <span class="hljs-keyword">return</span> <span class="hljs-title class_">Response</span>.<span class="hljs-title function_">json</span>(content)
}

Step 7: Protect Client Components

typescript
<span class="hljs-string">&#x27;use client&#x27;</span>

<span class="hljs-keyword">import</span> { useSession } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@/lib/auth-client&#x27;</span>
<span class="hljs-keyword">import</span> { redirect } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;next/navigation&#x27;</span>

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">DashboardContent</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> { <span class="hljs-attr">data</span>: session, isPending } = <span class="hljs-title function_">useSession</span>()
  
  <span class="hljs-keyword">if</span> (isPending) {
    <span class="hljs-keyword">return</span> <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">LoadingSpinner</span> /&gt;</span></span>
  }
  
  <span class="hljs-keyword">if</span> (!session) {
    <span class="hljs-title function_">redirect</span>(<span class="hljs-string">&#x27;/login&#x27;</span>)
  }
  
  <span class="hljs-keyword">return</span> (
    <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">h1</span>&gt;</span>Welcome, {session.user.name}!<span class="hljs-tag">&lt;/<span class="hljs-name">h1</span>&gt;</span>
      {/* Dashboard content */}
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span>
  )
}

Step 8: Create Initial Admin User

typescript
<span class="hljs-comment">// scripts/create-admin.ts</span>
<span class="hljs-keyword">import</span> { auth } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@/lib/auth&#x27;</span>
<span class="hljs-keyword">import</span> { prisma } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@/lib/prisma&#x27;</span>

<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">createAdmin</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> email = <span class="hljs-string">&#x27;[email protected]&#x27;</span>
  <span class="hljs-keyword">const</span> password = <span class="hljs-string">&#x27;SecurePassword123!&#x27;</span>  <span class="hljs-comment">// Change this!</span>
  
  <span class="hljs-comment">// Check if user exists</span>
  <span class="hljs-keyword">const</span> existing = <span class="hljs-keyword">await</span> prisma.<span class="hljs-property">user</span>.<span class="hljs-title function_">findUnique</span>({
    <span class="hljs-attr">where</span>: { email }
  })
  
  <span class="hljs-keyword">if</span> (existing) {
    <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">&#x27;Admin already exists&#x27;</span>)
    <span class="hljs-keyword">return</span>
  }
  
  <span class="hljs-comment">// Hash password and create user</span>
  <span class="hljs-keyword">const</span> hashedPassword = <span class="hljs-keyword">await</span> auth.<span class="hljs-property">api</span>.<span class="hljs-title function_">hashPassword</span>(password)
  
  <span class="hljs-keyword">await</span> prisma.<span class="hljs-property">user</span>.<span class="hljs-title function_">create</span>({
    <span class="hljs-attr">data</span>: {
      email,
      <span class="hljs-attr">name</span>: <span class="hljs-string">&#x27;Leon&#x27;</span>,
      <span class="hljs-attr">password</span>: hashedPassword,
      <span class="hljs-attr">role</span>: <span class="hljs-string">&#x27;admin&#x27;</span>
    }
  })
  
  <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">&#x27;Admin created successfully&#x27;</span>)
}

<span class="hljs-title function_">createAdmin</span>()

Run it:

bash
npx tsx scripts/create-admin.ts

Security Improvements

Before (Hardcoded)

  • ❌ Credentials in code
  • ❌ Plaintext passwords
  • ❌ No session management
  • ❌ No rate limiting
  • ❌ No audit logs
  • ❌ Single user only

After (Better-Auth)

  • ✅ Credentials in database
  • ✅ Bcrypt password hashing
  • ✅ HttpOnly cookie sessions
  • ✅ Built-in rate limiting
  • ✅ Session tracking (IP, user agent)
  • ✅ Multi-user support
  • ✅ Role-based access control
  • ✅ OAuth support (Google, GitHub)
  • ✅ Email verification
  • ✅ Password reset flows

Common Mistakes to Avoid

1. Storing Passwords in Environment Variables

bash
<span class="hljs-comment"># .env</span>
ADMIN_PASSWORD=mypassword  <span class="hljs-comment"># ❌ Still plaintext</span>

No better than hardcoding. Use a proper user table.

2. Rolling Your Own Crypto

typescript
<span class="hljs-keyword">function</span> <span class="hljs-title function_">hashPassword</span>(<span class="hljs-params"><span class="hljs-attr">password</span>: <span class="hljs-built_in">string</span></span>) {
  <span class="hljs-keyword">return</span> <span class="hljs-title function_">btoa</span>(password)  <span class="hljs-comment">// ❌ Base64 is not encryption!</span>
}

Use battle-tested libraries. bcrypt, Argon2, scrypt. Never invent your own.

3. Storing Sessions in Local Storage

typescript
<span class="hljs-variable language_">localStorage</span>.<span class="hljs-title function_">setItem</span>(<span class="hljs-string">&#x27;session&#x27;</span>, sessionToken)  <span class="hljs-comment">// ❌ Vulnerable to XSS</span>

HttpOnly cookies can't be accessed by JavaScript. Safer.

4. No HTTPS in Production

typescript
<span class="hljs-attr">secure</span>: process.<span class="hljs-property">env</span>.<span class="hljs-property">NODE_ENV</span> === <span class="hljs-string">&#x27;production&#x27;</span>  <span class="hljs-comment">// ✅ Always use HTTPS in prod</span>

Unencrypted HTTP leaks session cookies. Always use HTTPS.

5. Ignoring Rate Limiting

Attackers will brute force your login. Better-Auth handles this:

typescript
<span class="hljs-attr">rateLimit</span>: {
  <span class="hljs-attr">enabled</span>: <span class="hljs-literal">true</span>,
  <span class="hljs-attr">maxAttempts</span>: <span class="hljs-number">5</span>,
  <span class="hljs-attr">windowMs</span>: <span class="hljs-number">15</span> * <span class="hljs-number">60</span> * <span class="hljs-number">1000</span>  <span class="hljs-comment">// 5 attempts per 15 minutes</span>
}

Migration Checklist

  • Install Better-Auth
  • Add User/Session/Account models to Prisma schema
  • Run database migrations
  • Configure Better-Auth with database adapter
  • Create auth API routes
  • Build sign-in/sign-up UI
  • Protect API routes with session checks
  • Protect client pages with useSession
  • Create initial admin user
  • Test login flow end-to-end
  • Enable HTTPS in production
  • Enable email verification (optional)
  • Add OAuth providers (optional)
  • Remove hardcoded credentials from code
  • Update environment variables
  • Audit Git history (rotate any leaked credentials)

When to Use Better-Auth vs. NextAuth

Use Better-Auth if:

  • You want full control over the database schema
  • You prefer Prisma over custom adapters
  • You need modern TypeScript support
  • You want built-in rate limiting

Use NextAuth if:

  • You need proven battle-tested stability (10+ years)
  • You have complex OAuth requirements
  • You prefer a larger ecosystem
  • You already use NextAuth

Both are great. I chose Better-Auth because it's built for modern Next.js and Prisma.

What's Next

Community Manager is getting its auth migration this week. Liberture already has Better-Auth implemented—see the admin login for reference.

Scout will follow. Then Robert Blog (if we add user accounts).

Security isn't optional. It's infrastructure.


Live example: liberture.com/admin-login
Better-Auth docs: better-auth.com

Next post: "Setting Up PostgreSQL on Hetzner and Optimizing for Production"