diff --git a/apps/dashboard-api/src/__tests__/auth.controller.test.js b/apps/dashboard-api/src/__tests__/auth.controller.test.js
index 40c70c5b..c6e09a62 100644
--- a/apps/dashboard-api/src/__tests__/auth.controller.test.js
+++ b/apps/dashboard-api/src/__tests__/auth.controller.test.js
@@ -70,6 +70,10 @@ jest.mock('@urbackend/common', () => {
email: z.string().email(),
password: z.string().min(1),
}),
+ registerSchema: z.object({
+ email: z.string().email(),
+ password: z.string().min(1),
+ }),
changePasswordSchema: z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(6),
diff --git a/apps/dashboard-api/src/controllers/auth.controller.js b/apps/dashboard-api/src/controllers/auth.controller.js
index 79811469..d4d2566d 100644
--- a/apps/dashboard-api/src/controllers/auth.controller.js
+++ b/apps/dashboard-api/src/controllers/auth.controller.js
@@ -8,6 +8,7 @@ const { sendOtp } = require("@urbackend/common");
const crypto = require("crypto");
const {
loginSchema,
+ registerSchema,
changePasswordSchema,
deleteAccountSchema,
onlyEmailSchema,
@@ -274,7 +275,7 @@ async function checkOtpCooldown(userId) {
module.exports.register = async (req, res) => {
try {
- const { email, password } = loginSchema.parse(req.body);
+ const { email, password } = registerSchema.parse(req.body);
const existingUser = await Developer.findOne({ email });
if (existingUser) return res.status(400).json({ error: "Email already exists" });
diff --git a/apps/web-dashboard/package.json b/apps/web-dashboard/package.json
index feba4a21..999616c1 100644
--- a/apps/web-dashboard/package.json
+++ b/apps/web-dashboard/package.json
@@ -27,7 +27,8 @@
"react-router-dom": "^7.9.6",
"recharts": "^3.5.1",
"remark-gfm": "^4.0.1",
- "tailwindcss": "^4.1.18"
+ "tailwindcss": "^4.1.18",
+ "zxcvbn": "^4.4.2"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
diff --git a/apps/web-dashboard/src/components/PasswordStrengthMeter.jsx b/apps/web-dashboard/src/components/PasswordStrengthMeter.jsx
new file mode 100644
index 00000000..d66ecda9
--- /dev/null
+++ b/apps/web-dashboard/src/components/PasswordStrengthMeter.jsx
@@ -0,0 +1,101 @@
+import { useEffect, useMemo } from 'react';
+import zxcvbn from 'zxcvbn';
+
+const MIN_SCORE = 2;
+
+const SCORE_CONFIG = [
+ { label: 'Very weak', color: 'var(--color-danger)' },
+ { label: 'Weak', color: '#f97316' },
+ { label: 'Fair', color: '#facc15' },
+ { label: 'Strong', color: 'var(--color-primary)' },
+ { label: 'Very strong', color: 'var(--color-primary)' },
+];
+
+function buildUserInputs(userInputs) {
+ const values = (userInputs || []).filter(Boolean).map((v) => String(v));
+ const tokens = values.flatMap((v) => v.split(/[\s@.+_-]+/));
+ return [...new Set([...values, ...tokens])].filter((v) => v.length > 2);
+}
+
+function PasswordStrengthMeter({ password, userInputs, onStrengthChange }) {
+ const result = useMemo(() => {
+ if (!password) return null;
+ return zxcvbn(password, buildUserInputs(userInputs));
+ }, [password, userInputs]);
+
+ useEffect(() => {
+ if (!onStrengthChange) return;
+ if (!result) {
+ onStrengthChange({ score: 0, isStrongEnough: false });
+ return;
+ }
+ onStrengthChange({
+ score: result.score,
+ isStrongEnough: result.score >= MIN_SCORE,
+ });
+ }, [result, onStrengthChange]);
+
+ if (!password) return null;
+
+ const { score, feedback } = result;
+ const config = SCORE_CONFIG[score];
+ const barWidth = ((score + 1) / 5) * 100;
+
+ return (
+
+ {/* Strength bar */}
+
+
+ {/* Score label */}
+
+
+ {config.label}
+
+ {score < MIN_SCORE && (
+
+ Too weak to submit
+
+ )}
+
+
+ {/* Warning */}
+ {feedback.warning && (
+
+ {feedback.warning}
+
+ )}
+
+ {/* Suggestions */}
+ {feedback.suggestions?.length > 0 && (
+
+ {feedback.suggestions.map((s) => (
+ -
+ {s}
+
+ ))}
+
+ )}
+
+ );
+}
+
+export default PasswordStrengthMeter;
diff --git a/apps/web-dashboard/src/pages/ForgotPassword.jsx b/apps/web-dashboard/src/pages/ForgotPassword.jsx
index 68219050..b3f8bafd 100644
--- a/apps/web-dashboard/src/pages/ForgotPassword.jsx
+++ b/apps/web-dashboard/src/pages/ForgotPassword.jsx
@@ -1,9 +1,10 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { Eye, EyeOff, KeyRound, Lock, Mail } from 'lucide-react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import AuthShell from '../components/AuthShell';
+import PasswordStrengthMeter from '../components/PasswordStrengthMeter';
import { useAuth } from '../context/AuthContext';
import api from '../utils/api';
@@ -16,6 +17,7 @@ function ForgotPassword() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
+ const [passwordStrength, setPasswordStrength] = useState({ score: 0, isStrongEnough: false });
const [formData, setFormData] = useState({
email: location.state?.email || '',
otp: '',
@@ -23,6 +25,11 @@ function ForgotPassword() {
confirmPassword: '',
});
+ const userInputs = useMemo(
+ () => [formData.email],
+ [formData.email]
+ );
+
useEffect(() => {
if (!authLoading && isAuthenticated) {
navigate('/dashboard', { replace: true });
@@ -92,6 +99,11 @@ function ForgotPassword() {
return;
}
+ if (!passwordStrength.isStrongEnough) {
+ toast.error('Please choose a stronger password.');
+ return;
+ }
+
setIsSubmitting(true);
const loadingToast = toast.loading('Resetting password...');
@@ -203,6 +215,11 @@ function ForgotPassword() {
{showNewPassword ? : }
+
@@ -232,7 +249,11 @@ function ForgotPassword() {
-
+
-
- {passwordChecks.map((check) => (
-
-
- {check.label}
-
- ))}
-
-
-
+ 0 && !passwordStrength.isStrongEnough)}
+ >
{isSubmitting ? 'Creating account...' : 'Create account'}
diff --git a/package-lock.json b/package-lock.json
index 7f68c65d..23f22751 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -107,7 +107,8 @@
"react-router-dom": "^7.9.6",
"recharts": "^3.5.1",
"remark-gfm": "^4.0.1",
- "tailwindcss": "^4.1.18"
+ "tailwindcss": "^4.1.18",
+ "zxcvbn": "^4.4.2"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -14312,6 +14313,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/zxcvbn": {
+ "version": "4.4.2",
+ "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz",
+ "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==",
+ "license": "MIT"
+ },
"packages/common": {
"name": "@urbackend/common",
"version": "0.10.0",
@@ -14334,7 +14341,8 @@
"multer": "^2.0.2",
"resend": "^6.6.0",
"uuid": "^9.0.1",
- "zod": "^4.1.13"
+ "zod": "^4.1.13",
+ "zxcvbn": "^4.4.2"
}
},
"sdks/urbackend-sdk": {
diff --git a/packages/common/package.json b/packages/common/package.json
index 2affd6e1..c557c499 100644
--- a/packages/common/package.json
+++ b/packages/common/package.json
@@ -22,6 +22,7 @@
"multer": "^2.0.2",
"resend": "^6.6.0",
"uuid": "^9.0.1",
- "zod": "^4.1.13"
+ "zod": "^4.1.13",
+ "zxcvbn": "^4.4.2"
}
}
diff --git a/packages/common/src/index.js b/packages/common/src/index.js
index b1cd27b7..3caff558 100644
--- a/packages/common/src/index.js
+++ b/packages/common/src/index.js
@@ -71,6 +71,7 @@ const {
const {
loginSchema,
signupSchema,
+ registerSchema,
changePasswordSchema,
deleteAccountSchema,
onlyEmailSchema,
@@ -114,6 +115,7 @@ const sessionManager = require("./utils/session.manager");
const planLimits = require("./utils/planLimits");
const AppError = require("./utils/AppError");
const { checkLockout, recordFailedAttempt, clearLockout } = require("./utils/loginLockout");
+const { validatePasswordStrength } = require("./utils/passwordStrength");
const { dispatchWebhooks } = require("./utils/webhookDispatcher");
const { getDayKey, getMonthKey, getEndOfMonthTtlSeconds, incrWithTtlAtomic } = require("./utils/usageCounter");
@@ -141,6 +143,7 @@ module.exports = {
sendProRequestConfirmationEmail,
loginSchema,
signupSchema,
+ registerSchema,
changePasswordSchema,
deleteAccountSchema,
onlyEmailSchema,
@@ -217,6 +220,7 @@ module.exports = {
enqueueCollectionCleanup,
syncCollectionCleanup,
initTrashCleanupWorker,
+ validatePasswordStrength,
dispatchWebhooks,
getDayKey,
getMonthKey,
diff --git a/packages/common/src/utils/input.validation.js b/packages/common/src/utils/input.validation.js
index 67b9f14b..05b592c0 100755
--- a/packages/common/src/utils/input.validation.js
+++ b/packages/common/src/utils/input.validation.js
@@ -4,6 +4,7 @@ const {
MAX_FIELD_DEPTH,
UNIQUE_SUPPORTED_TYPES,
} = require("./schema.constants");
+const { validatePasswordStrength } = require("./passwordStrength");
module.exports.loginSchema = z.object({
email: z
@@ -17,6 +18,27 @@ module.exports.loginSchema = z.object({
.max(100, { message: "Password is too long." }),
});
+module.exports.registerSchema = z.object({
+ email: z
+ .string()
+ .min(1, { message: "Email is required." })
+ .email({ message: "Invalid email format." })
+ .max(100, { message: "Email is too long." }),
+ password: z
+ .string()
+ .min(6, { message: "Password must be at least 6 characters" })
+ .max(100, { message: "Password is too long." }),
+}).superRefine((data, ctx) => {
+ const result = validatePasswordStrength(data.password);
+ if (result) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["password"],
+ message: result.message,
+ });
+ }
+});
+
module.exports.signupSchema = z.object({
username: z
.string()
@@ -33,11 +55,29 @@ module.exports.signupSchema = z.object({
.string()
.min(6, { message: "Password must be at least 6 characters." })
.max(100, { message: "Password is too long." }),
+}).superRefine((data, ctx) => {
+ const result = validatePasswordStrength(data.password);
+ if (result) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["password"],
+ message: result.message,
+ });
+ }
});
module.exports.changePasswordSchema = z.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z.string().min(6, "New password must be at least 6 characters"),
+}).superRefine((data, ctx) => {
+ const result = validatePasswordStrength(data.newPassword);
+ if (result) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["newPassword"],
+ message: result.message,
+ });
+ }
});
module.exports.deleteAccountSchema = z.object({
@@ -60,6 +100,15 @@ module.exports.resetPasswordSchema = z.object({
.string()
.min(6, "Password must be at least 6 characters")
.max(100, "Password is too long."),
+}).superRefine((data, ctx) => {
+ const result = validatePasswordStrength(data.newPassword);
+ if (result) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["newPassword"],
+ message: result.message,
+ });
+ }
});
module.exports.createProjectSchema = z.object({
@@ -508,6 +557,15 @@ module.exports.userSignupSchema = z.object({
.string()
.min(6, { message: "Password must be at least 6 characters." })
.max(100, { message: "Password is too long." }),
+}).superRefine((data, ctx) => {
+ const result = validatePasswordStrength(data.password);
+ if (result) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["password"],
+ message: result.message,
+ });
+ }
});
// Webhook event config schema for per-collection events
diff --git a/packages/common/src/utils/passwordStrength.js b/packages/common/src/utils/passwordStrength.js
new file mode 100644
index 00000000..78228d86
--- /dev/null
+++ b/packages/common/src/utils/passwordStrength.js
@@ -0,0 +1,26 @@
+const zxcvbn = require("zxcvbn");
+
+const MIN_SCORE = 2;
+const WEAK_PASSWORD_MESSAGE =
+ "Password is too weak. Try adding numbers, symbols, or more characters.";
+
+/**
+ * Validates password strength using zxcvbn.
+ * @param {string} password
+ * @returns {{ message: string } | null}
+ */
+function validatePasswordStrength(password) {
+ if (typeof password !== "string" || password.length === 0) {
+ return { message: WEAK_PASSWORD_MESSAGE };
+ }
+
+ const result = zxcvbn(password);
+
+ if (result.score < MIN_SCORE) {
+ return { message: WEAK_PASSWORD_MESSAGE };
+ }
+
+ return null;
+}
+
+module.exports = { validatePasswordStrength };