Security
securityApi — password changes, forgot/reset flow, verification resend, built on the apiRequest helper.
Security client
Wraps every endpoint from the Security guide into securityApi, built on the apiRequest helper. Account deletion lives on User management.
Protect the surrounding application too
Read Before you ship for permission boundaries, recovery-link handling, browser headers, and production configuration mistakes to avoid.
UI components
All UI examples on this page use shadcn/ui with the default base variant. Only layout and styling change between projects — the component API stays the same. Install with npx shadcn@latest add button input card label alert.
The wrapper
// lib/api/security.ts
import { apiRequest } from "./client";
const API_BASE = "/api";
export const securityApi = {
changePassword: (oldPassword: string, newPassword: string) =>
apiRequest(API_BASE, "POST", "/auth/change-password", { oldPassword, newPassword }),
requestSetPassword: () => apiRequest(API_BASE, "POST", "/auth/set-password/request"),
confirmSetPassword: (userId: string, code: string, newPassword: string) =>
apiRequest(API_BASE, "POST", "/auth/set-password/confirm", { userId, code, newPassword }),
forgotPassword: (email: string) =>
apiRequest(API_BASE, "POST", "/auth/forgot-password", { email }),
resetPassword: (code: string, newPassword: string) =>
apiRequest(API_BASE, "POST", "/auth/reset-password", { code, newPassword }),
resendVerification: () => apiRequest(API_BASE, "POST", "/auth/resend-verification"),
resendVerificationByEmail: (email: string) =>
apiRequest(API_BASE, "POST", "/auth/verify-email/resend", { email }),
};Error handling
// lib/security-errors.ts
const errorMessages: Record<string, string> = {
wrong_password: "Current password is incorrect",
weak_password: "Password doesn't meet requirements",
no_password: "No password set — use set-password instead",
invalid_code: "Invalid or expired code",
code_expired: "Code has expired — request a new one",
user_not_found: "User not found",
email_not_verified: "Email not verified",
rate_limit_exceeded: "Too many attempts — try again later",
};
export function getSecurityErrorMessage(err: { error?: string; message?: string }): string {
if (err.error && err.error in errorMessages) {
return errorMessages[err.error];
}
return err.message ?? "Something went wrong";
}Change password
For accounts that already have a password.
// components/change-password-form.tsx
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { securityApi } from "../lib/api/security";
import { getSecurityErrorMessage } from "../lib/security-errors";
export function ChangePasswordForm() {
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(false);
try {
await securityApi.changePassword(oldPassword, newPassword);
setSuccess(true);
setOldPassword("");
setNewPassword("");
} catch (err: any) {
setError(getSecurityErrorMessage(err));
} finally {
setLoading(false);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Change password</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert>
<AlertDescription>Password changed.</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="oldPassword">Current password</Label>
<Input id="oldPassword" type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} required />
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">New password</Label>
<Input id="newPassword" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required />
</div>
<Button type="submit" disabled={loading}>
{loading ? "Changing..." : "Change password"}
</Button>
</form>
</CardContent>
</Card>
);
}Forgot password
Public page for a user who's locked out — no session required.
// components/forgot-password-form.tsx
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { securityApi } from "../lib/api/security";
import { getSecurityErrorMessage } from "../lib/security-errors";
export function ForgotPasswordForm() {
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(false);
try {
await securityApi.forgotPassword(email);
setSuccess(true);
} catch (err: any) {
setError(getSecurityErrorMessage(err));
} finally {
setLoading(false);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Reset your password</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert>
<AlertDescription>Request accepted. If your account has a password, check your email for a reset link. Delivery may take a moment.</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</div>
<Button type="submit" disabled={loading}>
{loading ? "Sending..." : "Send reset link"}
</Button>
</form>
</CardContent>
</Card>
);
}Reset password
Public page — user lands here from the emailed link. The default email uses the URL query parameter token; send its value as the API's JSON field code. See emailed-link handling for referrer, history, and logging precautions.
// components/reset-password-form.tsx
"use client";
import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { securityApi } from "../lib/api/security";
import { getSecurityErrorMessage } from "../lib/security-errors";
export function ResetPasswordForm() {
const searchParams = useSearchParams();
const code = searchParams.get("token") ?? "";
const [newPassword, setNewPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(false);
try {
await securityApi.resetPassword(code, newPassword);
setSuccess(true);
} catch (err: any) {
setError(getSecurityErrorMessage(err));
} finally {
setLoading(false);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Set new password</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert>
<AlertDescription>Password reset. You can now log in.</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="newPassword">New password</Label>
<Input id="newPassword" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required />
</div>
<Button type="submit" disabled={loading || !code}>
{loading ? "Resetting..." : "Reset password"}
</Button>
</form>
</CardContent>
</Card>
);
}Set password (OAuth-only accounts)
For accounts created via OAuth that don't have a password yet. Request an email code, then enter it with the new password while signed in. The default template sends an eight-character code, not a link. The confirmation form below gets the user ID from the auth provider; adapt the local hook import to your application's file layout.
// components/set-password-request.tsx
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { securityApi } from "../lib/api/security";
import { getSecurityErrorMessage } from "../lib/security-errors";
export function SetPasswordRequest() {
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
async function handleRequest() {
setLoading(true);
setError(null);
setSuccess(false);
try {
await securityApi.requestSetPassword();
setSuccess(true);
} catch (err: any) {
setError(getSecurityErrorMessage(err));
} finally {
setLoading(false);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Set a password</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert>
<AlertDescription>Check your email for the code to set your password.</AlertDescription>
</Alert>
)}
<p className="text-sm text-muted-foreground">
Your account was created with OAuth. Set a password so you can also log in with email.
</p>
<Button onClick={handleRequest} disabled={loading}>
{loading ? "Sending..." : "Send set-password code"}
</Button>
</CardContent>
</Card>
);
}// components/set-password-confirm.tsx
"use client";
import { useState } from "react";
import { useAuth } from "./auth-provider";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { securityApi } from "../lib/api/security";
import { getSecurityErrorMessage } from "../lib/security-errors";
export function SetPasswordConfirm() {
const { user, refresh } = useAuth();
const userId = user?.id ?? "";
const [code, setCode] = useState("");
const [newPassword, setNewPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!userId) return;
setLoading(true);
setError(null);
setSuccess(false);
try {
await securityApi.confirmSetPassword(userId, code, newPassword);
setSuccess(true);
await refresh();
} catch (err: any) {
setError(getSecurityErrorMessage(err));
} finally {
setLoading(false);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Set your password</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert>
<AlertDescription>Password set. You can now log in with email and password.</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="setPasswordCode">Email code</Label>
<Input id="setPasswordCode" value={code} onChange={(e) => setCode(e.target.value.toUpperCase())} autoComplete="one-time-code" maxLength={8} minLength={8} required />
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">New password</Label>
<Input id="newPassword" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required />
</div>
<Button type="submit" disabled={loading || code.length !== 8 || !userId || success}>
{loading ? "Setting..." : "Set password"}
</Button>
</form>
</CardContent>
</Card>
);
}Resend verification
Two variants: logged-in user (resends to their own email) and public (for a different device).
// components/resend-verification.tsx
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { securityApi } from "../lib/api/security";
import { getSecurityErrorMessage } from "../lib/security-errors";
export function ResendVerificationButton() {
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleResend() {
setLoading(true);
setError(null);
setMessage(null);
try {
const result = await securityApi.resendVerification();
setMessage(result.codeSent ? "Verification email sent." : "Use the verification code already sent to your email.");
} catch (err: any) {
setError(getSecurityErrorMessage(err));
} finally {
setLoading(false);
}
}
return (
<div className="space-y-2">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{message && (
<Alert>
<AlertDescription>{message}</AlertDescription>
</Alert>
)}
<Button variant="outline" size="sm" onClick={handleResend} disabled={loading}>
{loading ? "Sending..." : "Resend verification email"}
</Button>
</div>
);
}// components/resend-verification-email.tsx
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { securityApi } from "../lib/api/security";
import { getSecurityErrorMessage } from "../lib/security-errors";
export function ResendVerificationByEmailForm() {
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(false);
try {
await securityApi.resendVerificationByEmail(email);
setSuccess(true);
} catch (err: any) {
setError(getSecurityErrorMessage(err));
} finally {
setLoading(false);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Resend verification email</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert>
<AlertDescription>Request accepted. If verification is needed, check your email for a code. Delivery may take a moment.</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</div>
<Button type="submit" disabled={loading}>
{loading ? "Sending..." : "Resend"}
</Button>
</form>
</CardContent>
</Card>
);
}Next
- Setup — the
apiRequesthelper this is built on - Security — full request/response/error reference and
SecurityConfig - User management — account deletion