Convex Authentication
Overview
This skill implements complete user authentication using Convex Auth built-in system, including signup with profile initialization, login, password reset, and session management integrated with Next.js 16.
Architecture
Auth Flow
1. User signs up with email + password
↓
2. Profile initialized in users table
(username, email, avatar_url, total_score, games_played)
↓
3. Auth token created
↓
4. User logged in, can access protected routes
↓
5. Can login/logout, update password
Convex Auth Integration
Note: Uses @convex-dev/auth 0.0.90, NOT custom auth.
// convex/auth.ts
import { ConvexAuth } from "@convex-dev/auth/server";
import { password } from "@convex-dev/auth/providers";
export const auth = new ConvexAuth({
providers: [password],
});
Database Schema
// Users table (extends Convex auth)
export const users = defineTable({
// Auth fields (built-in):
// - email: string (unique)
// - password: string (hashed)
// - isEmailVerified: boolean
// Extended fields:
username: v.string(), // Display name
avatar_url: v.optional(v.string()), // Profile image
total_score: v.number(), // Cumulative score
games_played: v.number(), // Total games
created_at: v.number(), // Signup timestamp
})
.index("by_email", ["email"])
.index("by_username", ["username"]);
Authentication Provider Setup
Convex Auth Config
// convex/auth.config.ts
import { defineAuth } from "@convex-dev/auth/server";
import { password } from "@convex-dev/auth/providers";
export default defineAuth({
providers: [
password({
minPasswordLength: 8,
maxPasswordLength: 128,
}),
],
callbacks: {
async onSignUp(req) {
// Called after successful signup
// User record created automatically
return req.identity;
},
async onSignIn(req) {
// Called on successful login
return req.identity;
},
},
});
Signup Mutation with Profile Initialization
signUpUser Mutation
export const signUpUser = mutation({
args: {
email: v.string(),
password: v.string(),
username: v.string(),
},
handler: async (ctx, args) => {
// 1. Validate input
if (args.username.length < 3 || args.username.length > 30) {
throw new Error("Username must be 3-30 characters");
}
// 2. Check username uniqueness
const existing = await ctx.db
.query("users")
.withIndex("by_username", (q) => q.eq("username", args.username))
.first();
if (existing) {
throw new Error("Username already taken");
}
// 3. Create auth account (handled by Convex Auth)
// 4. Initialize user profile
const userId = (await ctx.auth.getUserIdentity())?.tokenIdentifier;
if (!userId) {
throw new Error("Failed to create user account");
}
// 5. Store profile data
const now = Date.now();
await ctx.db.insert("users", {
email: args.email,
username: args.username,
avatar_url: null,
total_score: 0,
games_played: 0,
created_at: now,
});
return {
success: true,
userId,
username: args.username,
};
},
});
Zod Validation Schema
// lib/schemas.ts
import { z } from "zod";
export const signUpSchema = z.object({
email: z.string().email("Invalid email address"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.max(128, "Password too long"),
username: z
.string()
.min(3, "Username must be at least 3 characters")
.max(30, "Username must be at most 30 characters")
.regex(
/^[a-zA-Z0-9_-]+$/,
"Username can only contain letters, numbers, underscore, hyphen"
),
});
export const loginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(1, "Password required"),
});
export const updatePasswordSchema = z.object({
currentPassword: z.string(),
newPassword: z
.string()
.min(8, "Password must be at least 8 characters")
.refine(
(pwd) => /[A-Z]/.test(pwd),
"Password must contain uppercase letter"
)
.refine((pwd) => /[0-9]/.test(pwd), "Password must contain number"),
});
Custom Hooks
useAuthenticatedUser
// hooks/useAuth.ts
import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
interface AuthProfile {
user_id: string;
username: string;
email: string;
avatar_url?: string;
total_score: number;
games_played: number;
}
export function useAuthenticatedUser() {
const profile = useQuery(api.queries.profiles.getCurrentUserProfile);
return {
profile: profile as AuthProfile | null | undefined,
isLoading: profile === undefined,
isAuthenticated: profile !== null && profile !== undefined,
};
}
export function useAuthContext() {
const profile = useAuthenticatedUser();
return {
userId: profile.profile?.user_id,
username: profile.profile?.username,
email: profile.profile?.email,
isAuthenticated: profile.isAuthenticated,
isLoading: profile.isLoading,
};
}
getCurrentUserProfile Query
export const getCurrentUserProfile = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
return null;
}
const user = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
return user
? {
user_id: user._id,
username: user.username,
email: identity.email,
avatar_url: user.avatar_url,
total_score: user.total_score,
games_played: user.games_played,
}
: null;
},
});
React Components
AuthProvider Wrapper
// app/layout.tsx
import { ConvexAuthNextjsProvider } from "@convex-dev/auth/nextjs";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
<ConvexAuthNextjsProvider>
<ConvexProvider>{children}</ConvexProvider>
</ConvexAuthNextjsProvider>
</body>
</html>
);
}
SignUp Form
// components/auth/sign-up-form.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signUpSchema } from "@/lib/schemas";
import { useConvexAuth } from "@convex-dev/auth/react";
export function SignUpForm() {
const form = useForm({
resolver: zodResolver(signUpSchema),
defaultValues: {
email: "",
password: "",
username: "",
},
});
const { signUp } = useConvexAuth();
async function onSubmit(values: z.infer<typeof signUpSchema>) {
try {
await signUp("password", {
email: values.email,
password: values.password,
username: values.username,
});
// Navigate to success page
window.location.href = "/auth/sign-up-success";
} catch (error) {
form.setError("email", {
message: error instanceof Error ? error.message : "Signup failed",
});
}
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="email"
render={({ field }) => <EmailInput {...field} />}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => <UsernameInput {...field} />}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => <PasswordInput {...field} />}
/>
<button type="submit">Sign Up</button>
</form>
);
}
Login Form
// components/auth/login-form.tsx
export function LoginForm() {
const form = useForm({
resolver: zodResolver(loginSchema),
});
const { logIn } = useConvexAuth();
async function onSubmit(values: z.infer<typeof loginSchema>) {
try {
await logIn("password", {
email: values.email,
password: values.password,
});
window.location.href = "/gioca";
} catch (error) {
form.setError("email", {
message: "Invalid email or password",
});
}
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Form fields */}
<button type="submit">Log In</button>
</form>
);
}
Session Management
Check Auth Status
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuthenticatedUser();
if (isLoading) {
return <LoadingSpinner />;
}
if (!isAuthenticated) {
return <Redirect to="/auth/login" />;
}
return <>{children}</>;
}
Logout
export function LogoutButton() {
const { logOut } = useConvexAuth();
const handleLogout = async () => {
await logOut();
window.location.href = "/";
};
return <button onClick={handleLogout}>Logout</button>;
}
Password Reset Flow
Request Password Reset
export const requestPasswordReset = mutation({
args: { email: v.string() },
handler: async (ctx, args) => {
const user = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", args.email))
.first();
if (!user) {
// Don't reveal if user exists
return { success: true };
}
// Generate reset token (send via email)
// Store token in database with expiry
return { success: true };
},
});
Update Password
export const updatePassword = mutation({
args: {
currentPassword: v.string(),
newPassword: v.string(),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
// Verify current password before updating
// Update with new password hash
return { success: true };
},
});
Environment Variables
# .env.local
CONVEX_DEPLOYMENT=dev
NEXT_PUBLIC_CONVEX_URL=https://xxx.convex.cloud
# Auth specific
CONVEX_AUTH_CALLBACK=http://localhost:3000 # For development
Common Patterns
Get current user ID
const { userId } = useAuthContext();
Redirect non-authenticated users
useEffect(() => {
if (!isAuthenticated && !isLoading) {
router.push("/auth/login");
}
}, [isAuthenticated, isLoading, router]);
Update user score after game
const updateScore = useMutation(api.mutations.profiles.updateUserScore);
await updateScore({
user_id: userId,
points: 150,
});
Best Practices
✅ Always validate email format server-side ✅ Hash passwords (Convex Auth does this automatically) ✅ Check username uniqueness before signup ✅ Use protected routes for authenticated pages ✅ Log auth events for security monitoring ✅ Implement rate limiting on auth endpoints ✅ Use secure HTTP-only cookies for tokens ✅ Validate password strength requirements
See Also
convex/auth.ts- Auth configurationconvex/auth.config.ts- Auth providers setupconvex/queries/profiles.ts- User profile queriescomponents/auth/- Auth UI components- Convex Auth docs: https://docs.convex.dev/auth/overview
微信扫一扫