Email and password authentication provides a traditional credential-based login system with secure password hashing using bcryptjs.
Overview
This authentication method allows users to:
- Sign up with email and password
- Sign in with their credentials
- Optional: Verify their email address
Passwords are hashed using bcryptjs with 12 salt rounds before storage.
Setup
import { createAuth } from '@arraf-auth/core'
import { prismaAdapter } from '@arraf-auth/adapter-prisma'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const auth = createAuth({
secret: process.env.AUTH_SECRET!,
database: prismaAdapter(prisma),
trustedOrigins: ['http://localhost:3000', 'https://yourdomain.com']
})
Create endpoints for sign up and sign in:
// app/api/auth/sign-up/route.ts
import { auth } from '@/lib/auth'
export async function POST(req: Request) {
return auth.handler('/auth/sign-up', req)
}
// app/api/auth/sign-in/route.ts
export async function POST(req: Request) {
return auth.handler('/auth/sign-in', req)
}
Sign Up
Create a new user account with email and password.
Request
POST /auth/sign-up
Content-Type: application/json
{
"email": "[email protected]",
"password": "SecurePass123!",
"name": "Ahmed Mohammed" // Optional
}
Passwords must be at least 8 characters long. This validation is enforced by the Zod schema in sign-up.ts:8.
Response
{
"user": {
"id": "user_abc123",
"email": "[email protected]",
"phone": null
},
"session": {
"id": "session_xyz789",
"token": "eyJhbGc...",
"expiresAt": "2024-12-31T23:59:59Z"
}
}
The response includes a Set-Cookie header that sets the session token.
Error Responses
// Email already in use
{
"error": "Email already in use"
}
// Password too short
{
"error": {
"fieldErrors": {
"password": ["String must contain at least 8 character(s)"]
}
}
}
// Invalid email format
{
"error": {
"fieldErrors": {
"email": ["Invalid email"]
}
}
}
Sign In
Authenticate an existing user with their email and password.
Request
POST /auth/sign-in
Content-Type: application/json
{
"method": "email",
"email": "[email protected]",
"password": "SecurePass123!"
}
Response
{
"user": {
"id": "user_abc123",
"email": "[email protected]",
"phone": null
},
"session": {
"id": "session_xyz789",
"token": "eyJhbGc...",
"expiresAt": "2024-12-31T23:59:59Z"
}
}
Error Response
{
"error": "Invalid credentials"
}
The error message is intentionally generic to prevent user enumeration attacks. Both “user not found” and “wrong password” return the same error.
Password Hashing
Arraf Auth uses bcryptjs for secure password hashing:
// From packages/core/src/password.ts
import bcrypt from "bcryptjs"
const SALT_ROUNDS = 12
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS)
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash)
}
The hashed password is stored in the Account table with:
providerId: "credential"
accountId: User ID
accessToken: Hashed password
Email Verification
While sign-up works without email verification, you can implement an email verification flow using the built-in verification system.
Send Verification Email
import { createAuth } from '@arraf-auth/core'
const auth = createAuth({
// ... your config
})
// After sign up
const verification = await auth.adapter.createVerification({
identifier: user.email,
token: generateVerificationToken(),
type: 'email-verification',
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
attempts: 0
})
// Send email with verification link
await sendEmail({
to: user.email,
subject: 'Verify your email',
html: `Click here to verify: https://yourapp.com/verify?token=${verification.token}`
})
Verify Email Endpoint
// app/api/auth/verify-email/route.ts
import { auth } from '@/lib/auth'
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const token = searchParams.get('token')
if (!token) {
return Response.json({ error: 'Missing token' }, { status: 400 })
}
// Find verification by token
const verification = await auth.adapter.findVerification(
token,
'email-verification'
)
if (!verification) {
return Response.json({ error: 'Invalid token' }, { status: 400 })
}
if (verification.expiresAt < new Date()) {
await auth.adapter.deleteVerification(verification.id)
return Response.json({ error: 'Token expired' }, { status: 400 })
}
// Update user
const user = await auth.adapter.findUserByEmail(verification.identifier)
if (user) {
await auth.adapter.updateUser(user.id, { emailVerified: true })
await auth.adapter.deleteVerification(verification.id)
}
return Response.json({ success: true })
}
Client Implementation
Example React hook for email/password authentication:
import { useState } from 'react'
interface SignUpData {
email: string
password: string
name?: string
}
interface SignInData {
email: string
password: string
}
export function useEmailAuth() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const signUp = async (data: SignUpData) => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/auth/sign-up', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
const result = await res.json()
if (!res.ok) {
throw new Error(result.error || 'Sign up failed')
}
return result
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
throw err
} finally {
setLoading(false)
}
}
const signIn = async (data: SignInData) => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/auth/sign-in', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
method: 'email',
...data
})
})
const result = await res.json()
if (!res.ok) {
throw new Error(result.error || 'Sign in failed')
}
return result
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
throw err
} finally {
setLoading(false)
}
}
return { signUp, signIn, loading, error }
}
Password Reset Flow
Implement password reset using the verification system:
// 1. Request password reset
export async function requestPasswordReset(email: string) {
const user = await auth.adapter.findUserByEmail(email)
if (!user) {
// Don't reveal if email exists
return { success: true }
}
const token = generateResetToken()
await auth.adapter.createVerification({
identifier: email,
token,
type: 'password-reset',
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
attempts: 0
})
await sendPasswordResetEmail(email, token)
return { success: true }
}
// 2. Reset password with token
export async function resetPassword(token: string, newPassword: string) {
const verification = await auth.adapter.findVerification(
token,
'password-reset'
)
if (!verification || verification.expiresAt < new Date()) {
throw new Error('Invalid or expired token')
}
const user = await auth.adapter.findUserByEmail(verification.identifier)
if (!user) throw new Error('User not found')
// Update password
const hash = await hashPassword(newPassword)
const account = await auth.adapter.findAccount('credential', user.id)
if (account) {
await auth.adapter.updateAccount(account.id, { accessToken: hash })
}
await auth.adapter.deleteVerification(verification.id)
return { success: true }
}
Combined Email/Phone Sign Up
You can allow users to sign up with both email and phone:
POST /auth/sign-up
Content-Type: application/json
{
"email": "[email protected]",
"phone": "0512345678",
"password": "SecurePass123!",
"name": "Ahmed"
}
The validation schema requires either email or phone, but allows both (see sign-up.ts:10-16).
Security Best Practices
Strong Password Requirements
Enforce password complexity on the client side:
function validatePassword(password: string): string[] {
const errors: string[] = []
if (password.length < 8) {
errors.push('Password must be at least 8 characters')
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain an uppercase letter')
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain a lowercase letter')
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain a number')
}
return errors
}
Prevent brute force attacks:
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '15 m'), // 5 attempts per 15 minutes
})
export async function POST(req: Request) {
const body = await req.json()
const { success } = await ratelimit.limit(body.email)
if (!success) {
return Response.json(
{ error: 'Too many login attempts. Please try again later.' },
{ status: 429 }
)
}
return auth.handler('/auth/sign-in', req)
}
Use hooks to add security checks:
const securityPlugin = {
id: 'security',
hooks: {
beforeSignIn: async (user) => {
// Check if account is locked
const lockStatus = await checkAccountLock(user.id)
if (lockStatus.locked) {
throw new Error('Account is temporarily locked')
}
},
afterSignIn: async (user, session) => {
// Clear failed login attempts
await clearFailedAttempts(user.id)
// Log successful login
await logSecurityEvent({
userId: user.id,
event: 'sign_in',
ip: session.ipAddress,
userAgent: session.userAgent
})
}
}
}
Production Recommendations:
- Implement rate limiting on sign-in attempts
- Add CAPTCHA after failed login attempts
- Send email notifications for new sign-ins
- Require email verification for sensitive operations
- Use HTTPS in production (set
secure: true in cookie options)
Next Steps