Authentication
How sign-in works (email and password, Google, GitHub), how to read the signed-in user, and how to protect a page.
Sign-in is built on Better Auth and configured in src/lib/auth.ts. Users, sessions and accounts are stored in your Postgres database.
Sign-in methods
| Method | Turns on when | Notes |
|---|---|---|
| Email and password | Always in development. In production, only when RESEND_API_KEY is set | Email must be confirmed before the first sign-in. Minimum 8 characters |
GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set | See Google and GitHub sign-in | |
| GitHub | GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET are set | Same |
Why email sign-in disappears in production
Email sign-in needs to send confirmation and password reset emails. In production without Resend, those emails can't be sent, so the form is hidden and only social sign-in is offered. Set up Resend to bring it back.
The email and password flow
- Sign up. The user enters name, email and password. A confirmation email is sent. Locally without Resend, the link prints in your
npm run devterminal, and the sign-up page says so. - Confirm. Clicking the link confirms the email and signs the user in.
- Sign in. If the email isn't confirmed yet, sign-in fails with "Please confirm your email first. We sent you a new link." and a new link is sent.
- Forgot password.
/forgot-passwordsends a reset link. Resetting the password signs the user out on every other device.
Signing up again with an email that already has an account shows the same screen as a new sign-up (so nobody can find out which emails have accounts), and the owner of that email gets a message telling them they already have an account.
Social sign-in and existing accounts
- Same email, different method: someone who signed up with email and password can later use "Continue with Google" with the same email. Both sign into one account.
- Unconfirmed email account: if someone signed up with an email but never confirmed it, then uses Google or GitHub with that email (which the provider has verified), they're signed in and the unconfirmed password is removed. That stops a stranger who registered someone else's email first from keeping a way in.
- Provider didn't verify the email: sign-in is refused with
account_not_linked. See Sign-in errors.
Rate limiting
Better Auth's rate limits are on, and stored in Postgres so they hold across all of Vercel's server instances. Too many attempts return 429 Too Many Requests for a short while.
Reading the signed-in user
Use the helpers in src/lib/session.ts. Never read cookies yourself.
import { Suspense } from 'react';
import { requireUser } from '@/lib/session';
export default function SettingsPage() {
return (
<Suspense fallback={<p>Loading…</p>}>
<Settings />
</Suspense>
);
}
async function Settings() {
// Redirects to /sign-in when nobody is signed in.
const user = await requireUser();
return <p>Signed in as {user.email}</p>;
}| Helper | Returns |
|---|---|
getSession() | The session (with .user), or null |
requireUser() | The user, or redirects to /sign-in |
requireAdmin() (in src/lib/admin.ts) | The admin user, or a 404 page for everyone else |
Read the session inside Suspense
The kit uses Next.js 16 with cacheComponents: true. Reading the session, headers(), cookies() or searchParams must happen inside a component wrapped in <Suspense>, as above. Awaiting them at the top of a page fails the build. See Build errors.
Protecting pages
Each page checks the user itself with requireUser() or requireAdmin(). src/proxy.ts also redirects visitors without a session cookie away from /dashboard and /admin, but only to save a round trip: it doesn't check that the session is valid, so it's not a security check.
Add new signed-in pages under src/app/dashboard/.
Signing in on the client
'use client';
import { authClient } from '@/lib/auth-client';
await authClient.signIn.social({ provider: 'google', callbackURL: '/dashboard' });
await authClient.signOut();The auth secret
BETTER_AUTH_SECRET signs session cookies. Changing it signs everyone out. Use a different value in each project, and never commit it.