Skip to main content

Authentication

FTB Hustle uses Better Auth for secure, modern authentication. This guide covers our implementation, OAuth providers, and email verification flows.
Supported Authentication Methods:
  • Google OAuth 2.0
  • LinkedIn OAuth 2.0
  • Email & Password (with verification)

Authentication Architecture

Tech Stack

Our authentication system is built with:
  • Better Auth: Modern auth library for Next.js
  • Drizzle ORM: Type-safe database operations
  • PostgreSQL: Reliable user data storage
  • Resend: Transactional email service
  • React Email: Beautiful email templates
// lib/auth.ts - Server-side auth configuration
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { db } from "./db";
import { schema } from "./schema";
import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

export const auth = betterAuth({
  user: {
    additionalFields: {
      role: {
        type: ["user", "member", "admin"],
        required: false,
        defaultValue: "user",
        input: false
      }
    }
  },
  emailVerification: {
    sendVerificationEmail: async ({ user, url }) => {
      await resend.emails.send({
        from: `${process.env.EMAIL_SENDER_NAME} <${process.env.EMAIL_SENDER_ADDRESS}>`,
        to: user.email,
        subject: "Verify your email",
        react: VerifyEmail({ username: user.name, verifyUrl: url })
      });
    },
    sendOnSignUp: true
  },
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true
  },
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!
    },
    linkedin: {
      clientId: process.env.LINKEDIN_CLIENT_ID!,
      clientSecret: process.env.LINKEDIN_CLIENT_SECRET!
    }
  },
  database: drizzleAdapter(db, {
    provider: "pg",
    schema
  }),
  plugins: [nextCookies()]
});

Client-Side Setup

// lib/auth-client.ts - Client-side auth client
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL
});

Google OAuth Integration

Google OAuth is the recommended sign-in method for fastest onboarding.

Implementation

// components/auth/login-form.tsx
const signInWithGoogle = async () => {
  try {
    setIsLoading(true);
    await authClient.signIn.social({
      provider: "google",
      callbackURL: returnUrl,
      newUserCallbackURL: "/onboarding"
    });
  } catch (error) {
    console.error("Google sign-in error:", error);
    toast.error("Google sign-in failed. Please try again.");
  } finally {
    setIsLoading(false);
  }
};

Setup Instructions

1

Create Google OAuth App

Go to Google Cloud Console and create a new project.
2

Enable Google+ API

Navigate to “APIs & Services” > “Library” and enable the Google+ API.
3

Create OAuth Credentials

Go to “Credentials” > “Create Credentials” > “OAuth client ID”.
  • Application type: Web application
  • Authorized redirect URIs: https://yourdomain.com/api/auth/callback/google
4

Add to Environment Variables

Copy the Client ID and Client Secret to your .env.local file:
GOOGLE_CLIENT_ID=your_client_id
GOOGLE_CLIENT_SECRET=your_client_secret

OAuth Flow

1

User clicks 'Login with Google'

The signInWithGoogle function is triggered.
2

Redirect to Google

User is redirected to Google’s OAuth consent screen.
3

User authorizes

User grants permission for FTB Hustle to access basic profile info.
4

Callback to our server

Google redirects back to /api/auth/callback/google with auth code.
5

Session created

Better Auth exchanges the code for tokens and creates a session.
6

User redirected

  • New users → /onboarding
  • Returning users → /opportunities (or returnUrl)
New vs Returning Users: We detect new users by comparing createdAt and updatedAt timestamps. New users are redirected to onboarding.

LinkedIn OAuth Integration

LinkedIn OAuth is ideal for professionals and students building their network.

Implementation

const signInWithLinkedIn = async () => {
  try {
    setIsLoading(true);
    await authClient.signIn.social({
      provider: "linkedin",
      callbackURL: returnUrl,
      newUserCallbackURL: "/onboarding"
    });
  } catch (error) {
    console.error("LinkedIn sign-in error:", error);
    toast.error("LinkedIn sign-in failed. Please try again.");
  } finally {
    setIsLoading(false);
  }
};

Setup Instructions

1

Create LinkedIn App

Go to LinkedIn Developers and create a new app.
2

Request OAuth 2.0 scopes

Request the following permissions:
  • r_liteprofile (name, profile picture)
  • r_emailaddress (email address)
3

Configure Redirect URLs

Add authorized redirect URL: https://yourdomain.com/api/auth/callback/linkedin
4

Add to Environment Variables

LINKEDIN_CLIENT_ID=your_client_id
LINKEDIN_CLIENT_SECRET=your_client_secret
LinkedIn OAuth requires your app to be verified if you want to access certain scopes. For basic profile info, verification isn’t required.

Email & Password Authentication

Email/password auth includes built-in validation, hashing, and email verification.

Sign Up Flow

// components/auth/signup-form.tsx
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

const formSchema = z.object({
  username: z.string().min(3),
  email: z.string().email(),
  password: z.string().min(8)
});

type FormData = z.infer<typeof formSchema>;

const form = useForm<FormData>({
  resolver: zodResolver(formSchema),
  defaultValues: {
    username: "",
    email: "",
    password: ""
  }
});

async function onSubmit(values: FormData) {
  setIsLoading(true);

  try {
    await authClient.signUp.email(
      {
        email: values.email,
        password: values.password,
        name: values.username,
        callbackURL: "/dashboard"
      },
      {
        onSuccess: (ctx) => {
          const isNewUser = 
            ctx.data?.user?.createdAt === ctx.data?.user?.updatedAt;

          if (isNewUser) {
            router.push("/onboarding");
          } else {
            router.push("/dashboard");
          }
        }
      }
    );

    toast.success("Account created. Redirecting...");
  } catch (error) {
    console.error("Signup error:", error);
    toast.error("Signup failed. Please try again.");
  } finally {
    setIsLoading(false);
  }
}

Login Flow

// components/auth/login-form.tsx
const formSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8)
});

async function onSubmit(values: z.infer<typeof formSchema>) {
  setIsLoading(true);

  try {
    await authClient.signIn.email(
      {
        email: values.email,
        password: values.password
      },
      {
        onSuccess: async (ctx) => {
          // Check if user has completed onboarding
          try {
            const response = await fetch("/api/onboarding");
            const data = await response.json();

            const hasCompletedOnboarding = !!data.profile;

            if (hasCompletedOnboarding) {
              router.push(returnUrl);
            } else {
              // Fallback to timestamp comparison
              const createdAt = ctx.data?.user?.createdAt;
              const updatedAt = ctx.data?.user?.updatedAt;

              const isNewUser = createdAt && updatedAt
                ? new Date(createdAt).getTime() === new Date(updatedAt).getTime()
                : false;

              router.push(isNewUser ? "/onboarding" : returnUrl);
            }
          } catch (error) {
            console.error("Error checking onboarding status:", error);
            router.push(returnUrl);
          }
        }
      }
    );
    toast.success("Logged in. Redirecting...");
  } catch (error) {
    console.error("Login error:", error);
    toast.error("Login failed. Please try again.");
  } finally {
    setIsLoading(false);
  }
}
Password Requirements: Minimum 8 characters. Passwords are hashed using bcrypt before storage.

Email Verification

Email verification is required for all email/password signups to ensure account security.

Verification Email Template

// components/auth/verify-email.tsx
import * as React from "react";
import {
  Html, Head, Body, Container, Section, Text, Button, Hr, Tailwind
} from "@react-email/components";

interface VerifyEmailProps {
  username: string;
  verifyUrl: string;
}

const VerifyEmail = ({ username, verifyUrl }: VerifyEmailProps) => {
  return (
    <Html lang="en" dir="ltr">
      <Tailwind>
        <Head />
        <Body className="bg-gray-100 font-sans py-[40px]">
          <Container className="bg-white rounded-[8px] p-[32px] max-w-[600px] mx-auto">
            <Section>
              <Text className="text-[24px] font-bold text-gray-900 mb-[16px]">
                Verify your email address
              </Text>

              <Text className="text-[16px] text-gray-700 mb-[24px]">
                Thanks {username} for signing up! To complete your registration
                and secure your account, please verify your email address by
                clicking the button below.
              </Text>

              <Section className="text-center mb-[32px]">
                <Button
                  href={verifyUrl}
                  className="bg-blue-600 text-white px-[32px] py-[12px] rounded-[6px]"
                >
                  Verify Email Address
                </Button>
              </Section>

              <Text className="text-[14px] text-gray-600">
                If the button doesn't work, copy and paste this link:
                <br />
                {verifyUrl}
              </Text>

              <Text className="text-[14px] text-gray-600">
                This verification link will expire in 24 hours.
              </Text>
            </Section>
          </Container>
        </Body>
      </Tailwind>
    </Html>
  );
};

export default VerifyEmail;

Sending Verification Emails

// lib/auth.ts - Email verification config
emailVerification: {
  sendVerificationEmail: async ({ user, url }) => {
    await resend.emails.send({
      from: `${process.env.EMAIL_SENDER_NAME} <${process.env.EMAIL_SENDER_ADDRESS}>`,
      to: user.email,
      subject: "Verify your email",
      react: VerifyEmail({ 
        username: user.name, 
        verifyUrl: url 
      })
    });
  },
  sendOnSignUp: true  // Automatically send on signup
}

Email Configuration

1

Sign up for Resend

Create an account at resend.com
2

Verify your domain

Add DNS records to verify your sending domain.
3

Create API key

Generate an API key from the Resend dashboard.
4

Add to environment variables

RESEND_API_KEY=your_api_key
EMAIL_SENDER_NAME="FTB Hustle"
EMAIL_SENDER_ADDRESS="noreply@yourdomain.com"
Email Deliverability: Make sure to verify your domain with Resend to avoid emails landing in spam. Test with your own email first.

Password Reset Flow

Users can reset their password if they forget it.

Forgot Password Form

// components/auth/forgot-password-form.tsx
const formSchema = z.object({
  email: z.string().email()
});

async function onSubmit(values: z.infer<typeof formSchema>) {
  setIsLoading(true);

  try {
    await authClient.forgetPassword({
      email: values.email,
      redirectTo: "/reset-password"
    });

    toast.success("Password reset email sent. Check your inbox.");
  } catch (error) {
    console.error("Forgot password error:", error);
    toast.error("Failed to send reset email.");
  } finally {
    setIsLoading(false);
  }
}

Reset Password Email

// components/auth/reset-password.tsx
const ForgotPasswordEmail = ({ 
  username, 
  resetUrl, 
  userEmail 
}: ForgotPasswordEmailProps) => {
  return (
    <Html>
      <Tailwind>
        <Body>
          <Container>
            <Text className="text-[24px] font-bold">
              Reset your password
            </Text>
            <Text>
              Hi {username}, we received a request to reset your password.
            </Text>
            <Button href={resetUrl}>
              Reset Password
            </Button>
            <Text>
              If you didn't request this, you can safely ignore this email.
            </Text>
          </Container>
        </Body>
      </Tailwind>
    </Html>
  );
};

Sending Reset Emails

// lib/auth.ts - Password reset config
emailAndPassword: {
  enabled: true,
  sendResetPassword: async ({ user, url }) => {
    await resend.emails.send({
      from: `${process.env.EMAIL_SENDER_NAME} <${process.env.EMAIL_SENDER_ADDRESS}>`,
      to: user.email,
      subject: "Reset your password",
      react: ForgotPasswordEmail({
        username: user.name,
        resetUrl: url,
        userEmail: user.email
      })
    });
  },
  requireEmailVerification: true
}

Session Management

Better Auth handles sessions using secure HTTP-only cookies.

Accessing Session Data

// Using the useSession hook (client-side)
import { useSession } from "@/hooks/use-session";

function MyComponent() {
  const { data: session, status } = useSession();

  if (status === "loading") {
    return <div>Loading...</div>;
  }

  if (!session) {
    return <div>Not logged in</div>;
  }

  return (
    <div>
      Welcome, {session.user.name}!
      <p>Email: {session.user.email}</p>
      <p>Role: {session.user.role}</p>
    </div>
  );
}

Server-Side Session Access

// In API routes or Server Components
import { auth } from "@/lib/auth";
import { headers } from "next/headers";

export async function GET() {
  const session = await auth.api.getSession({
    headers: await headers()
  });

  if (!session) {
    return Response.json(
      { error: "Unauthorized" }, 
      { status: 401 }
    );
  }

  // Access user data
  const userId = session.user.id;
  const userRole = session.user.role;

  // Your logic here
}

Sign Out

const handleSignOut = async () => {
  await authClient.signOut({
    fetchOptions: {
      onSuccess: () => {
        router.push("/");
        toast.success("Signed out successfully");
      }
    }
  });
};

Database Schema

Better Auth automatically creates and manages the following tables:
// lib/schema.ts - User schema
export const user = pgTable("user", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  emailVerified: boolean("email_verified").notNull().default(false),
  image: text("image"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
  // Custom field
  role: text("role", { enum: ["user", "member", "admin"] })
    .default("user")
});

// Additional tables managed by Better Auth:
// - session
// - account (for OAuth providers)
// - verification (for email verification tokens)

Security Best Practices

Password Hashing

All passwords are hashed using bcrypt with salt rounds before storage. Never store plaintext passwords.

HTTPS Only

All authentication flows require HTTPS in production. Cookies are marked as secure and HTTP-only.

CSRF Protection

Better Auth includes built-in CSRF protection for all auth endpoints.

Email Verification

Required for email/password signups. Verification links expire after 24 hours.

Environment Variables

Complete list of required environment variables:
# App URL
NEXT_PUBLIC_APP_URL=https://yourdomain.com

# Database
DATABASE_URL=postgresql://user:password@host:port/database

# Better Auth Secret
BETTER_AUTH_SECRET=your_random_secret_key

# Google OAuth
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret

# LinkedIn OAuth
LINKEDIN_CLIENT_ID=your_linkedin_client_id
LINKEDIN_CLIENT_SECRET=your_linkedin_client_secret

# Email (Resend)
RESEND_API_KEY=your_resend_api_key
EMAIL_SENDER_NAME="FTB Hustle"
EMAIL_SENDER_ADDRESS=noreply@yourdomain.com

API Routes

Better Auth provides the following API routes automatically:
  • POST /api/auth/sign-in/email - Email/password login
  • POST /api/auth/sign-up/email - Email/password signup
  • GET /api/auth/sign-in/social - OAuth login (Google, LinkedIn)
  • POST /api/auth/sign-out - Sign out
  • GET /api/auth/session - Get current session
  • POST /api/auth/forget-password - Request password reset
  • POST /api/auth/reset-password - Reset password
  • GET /api/auth/verify-email - Verify email address

Troubleshooting

  • Verify redirect URIs match exactly in OAuth provider settings
  • Check that NEXT_PUBLIC_APP_URL is set correctly
  • Ensure cookies are enabled in browser
  • Check spam folder
  • Verify Resend domain is authenticated
  • Check Resend dashboard for delivery logs
  • Ensure EMAIL_SENDER_ADDRESS matches verified domain
  • Check that cookies are enabled
  • Verify BETTER_AUTH_SECRET is set
  • Ensure app is running on HTTPS in production
  • Verify email is registered
  • Check that reset token hasn’t expired (24 hours)
  • Ensure redirectTo URL is correct

You now have a complete understanding of FTB Hustle’s authentication system. For more information, visit the Better Auth documentation.