Password Confirmation Validation with Zod
Problem Statement
When building user registration forms, requiring a password confirmation field helps ensure users enter their intended password correctly. However, Zod schemas treat each field independently by default. This presents a challenge: How do we validate that the password and confirm password fields match?
Without cross-field validation:
- Users can enter mismatched passwords
- Form submissions pass validation despite inconsistent values
- Security and user experience suffer
Zod doesn't provide built-in functionality to compare two fields directly. We need to implement custom validation that analyzes both fields simultaneously.
Solutions
There are two primary Zod methods for password confirmation validation:
1. Using .refine()
(Recommended for Most Cases)
.refine()
provides a straightforward way to implement custom validation logic. It's concise and ideal for simple field comparisons.
export const registerUserSchema = z.object({
firstName: z.string(),
lastName: z.string(),
userName: z.string(),
email: z.string().email(),
phone: z.string().transform(data => Number(data)),
password: z.string().min(4),
confirmPassword: z.string().min(4),
avatar: z.string().optional(),
isVerified: z.boolean().optional()
}).refine(
(data) => data.password === data.confirmPassword,
{
message: "Passwords must match!",
path: ["confirmPassword"],
}
);
Key features:
- First argument: Validation function returning boolean
- Options object:
message
: Custom error message displayed when validation failspath
: Array specifying which field to attach the error to
- Runs after individual field validations succeed
TIP
Use .refine()
when:
- You need a simple boolean comparison
- You want a clean, declarative syntax
- Your validation logic fits in a single expression
2. Using .superRefine()
(Advanced Control)
For complex validation scenarios or when you need fine-grained control over the error context, use .superRefine()
.
export const registerUserSchema = z.object({
// ...same field definitions
}).superRefine(({ confirmPassword, password }, ctx) => {
if (confirmPassword !== password) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords do not match",
path: ['confirmPassword']
});
}
});
Key features:
- Access to validation context (
ctx
) - Manual error handling with
.addIssue()
- Can validate multiple conditions with separate errors
- Supports complex validation logic
- Fine-grained control over error paths and messages
WARNING
Use .superRefine()
when:
- You need to add multiple independent errors
- Your validation requires complex conditional logic
- You need lower-level control than
.refine()
provides
Implementation Guide
1. Form Setup
<form>
<input type="password" name="password" placeholder="Password" />
<input type="password" name="confirmPassword" placeholder="Confirm Password" />
<!-- other fields -->
</form>
2. Validation Flow
- Individual field validations execute first (min length, email format, etc)
- If all field validations pass, cross-field validation executes
- Mismatches attach specifically to
confirmPassword
field
3. Error Handling Best Practices
- Display the error near the
confirmPassword
field - Clear errors only after editing the relevant field
- Highlight both password fields when mismatch occurs
{errors.confirmPassword && (
<p className="text-red-500">{errors.confirmPassword.message}</p>
)}
Common Pitfalls
Avoid These Mistakes
- Incorrect path definition:
path: ["confirmPassword"]
ensures errors attach to the right field - Weak base validation: Password length validation should execute BEFORE confirmation check
- Unsafe conversions: Ensure phone number transformations only happen AFTER validation
- Missing error propagation: Always include validation errors in your UI
Integration with Form Libraries
React Hook Form Example
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const FormComponent = () => {
const { register, handleSubmit, formState } = useForm({
resolver: zodResolver(registerUserSchema)
});
return (
<form onSubmit={handleSubmit(submitData)}>
<input {...register("password")} type="password" />
<input {...register("confirmPassword")} type="password" />
{formState.errors.confirmPassword && (
<p>{formState.errors.confirmPassword.message}</p>
)}
</form>
);
};
Conclusion
To validate password confirmation in Zod:
- Use
.refine()
for most cases when a simple field comparison suffices - Choose
.superRefine()
for complex validation requirements - Always attach errors specifically to
confirmPassword
using thepath
option - Apply base validations (like
min()
) before cross-field checks
Both methods ensure passwords match before submission, significantly improving form reliability and user experience while maintaining TypeScript type safety.