Skip to content

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:

.refine() provides a straightforward way to implement custom validation logic. It's concise and ideal for simple field comparisons.

js
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 fails
    • path: 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().

js
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

html
<form>
  <input type="password" name="password" placeholder="Password" />
  <input type="password" name="confirmPassword" placeholder="Confirm Password" />
  <!-- other fields -->
</form>

2. Validation Flow

  1. Individual field validations execute first (min length, email format, etc)
  2. If all field validations pass, cross-field validation executes
  3. 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
jsx
{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

jsx
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:

  1. Use .refine() for most cases when a simple field comparison suffices
  2. Choose .superRefine() for complex validation requirements
  3. Always attach errors specifically to confirmPassword using the path option
  4. 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.