From 9dbe0c4275e3676bf5bd44f72721b7ab54c56ec0 Mon Sep 17 00:00:00 2001 From: Hammad Rehman Date: Wed, 3 Jun 2026 11:12:18 +0100 Subject: [PATCH] feat(auth): add argon2 password hashing support to remove 72-byte limit bcrypt silently truncates passwords longer than 72 bytes, which is a known security footgun. This change introduces opt-in argon2 support via PASSWORD_HASH_ALGORITHM=argon2, which has no such limit. - Add PASSWORD_HASH_ALGORITHM env var (default: 'bcrypt' for backward compat) - get_password_hash() uses argon2 or bcrypt based on the setting - verify_password() auto-detects algorithm from hash prefix ($argon2 vs $2b$), so existing bcrypt hashes continue to work after switching algorithms - validate_password() only enforces the 72-byte limit when bcrypt is active - Remove the silent truncation hack in auths.py signin handler - Update PASSWORD_TOO_LONG error message to mention the escape hatch argon2-cffi was already a declared dependency. Co-Authored-By: Claude Sonnet 4.6 --- backend/open_webui/constants.py | 3 ++- backend/open_webui/env.py | 1 + backend/open_webui/routers/auths.py | 9 ------- backend/open_webui/utils/auth.py | 39 ++++++++++++++++++----------- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/backend/open_webui/constants.py b/backend/open_webui/constants.py index 132f3ac19a..0675d34771 100644 --- a/backend/open_webui/constants.py +++ b/backend/open_webui/constants.py @@ -26,7 +26,8 @@ class ERROR_MESSAGES(str, Enum): EMAIL_TAKEN = 'Uh-oh! This email is already registered. Sign in with your existing account or choose another email to start anew.' USERNAME_TAKEN = 'Uh-oh! This username is already registered. Please choose another username.' PASSWORD_TOO_LONG = ( - 'Uh-oh! The password you entered is too long. Please make sure your password is less than 72 bytes long.' + 'Uh-oh! The password you entered is too long. When using bcrypt, passwords must be 72 bytes or fewer. ' + 'Set PASSWORD_HASH_ALGORITHM=argon2 to remove this limit.' ) COMMAND_TAKEN = 'Uh-oh! This command is already registered. Please choose another command string.' FILE_EXISTS = 'Uh-oh! This file is already registered. Please choose another file.' diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 2f6b4ed632..27ec23feee 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -662,6 +662,7 @@ WEBUI_AUTH_TRUSTED_ROLE_HEADER = os.getenv('WEBUI_AUTH_TRUSTED_ROLE_HEADER', Non CUSTOM_API_KEY_HEADER = os.getenv('CUSTOM_API_KEY_HEADER', 'x-api-key') ENABLE_PASSWORD_VALIDATION = os.getenv('ENABLE_PASSWORD_VALIDATION', 'False').lower() == 'true' +PASSWORD_HASH_ALGORITHM = os.getenv('PASSWORD_HASH_ALGORITHM', 'bcrypt').lower() PASSWORD_VALIDATION_REGEX_PATTERN = os.getenv( 'PASSWORD_VALIDATION_REGEX_PATTERN', r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$', diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 434a6349de..b3ecdfafdb 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -651,15 +651,6 @@ async def signin( detail=ERROR_MESSAGES.RATE_LIMIT_EXCEEDED, ) - password_bytes = form_data.password.encode('utf-8') - if len(password_bytes) > 72: - # TODO: Implement other hashing algorithms that support longer passwords - log.info('Password too long, truncating to 72 bytes for bcrypt') - password_bytes = password_bytes[:72] - - # decode safely — ignore incomplete UTF-8 sequences - form_data.password = password_bytes.decode('utf-8', errors='ignore') - user = await Auths.authenticate_user( form_data.email.lower(), lambda pw: verify_password(form_data.password, pw), diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 26cea6b45f..f4e7f26722 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -11,6 +11,8 @@ from datetime import datetime, timedelta from typing import Optional, Union import bcrypt +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError import jwt import pytz import requests @@ -25,6 +27,7 @@ from open_webui.env import ( ENABLE_PASSWORD_VALIDATION, LICENSE_BLOB, OFFLINE_MODE, + PASSWORD_HASH_ALGORITHM, PASSWORD_VALIDATION_HINT, PASSWORD_VALIDATION_REGEX_PATTERN, REDIS_KEY_PREFIX, @@ -157,17 +160,21 @@ def get_license_data(app, key): bearer_security = HTTPBearer(auto_error=False) +_argon2_hasher = PasswordHasher() + + def get_password_hash(password: str) -> str: - """Hash a password using bcrypt""" + """Hash a password using the configured algorithm (bcrypt or argon2).""" + if PASSWORD_HASH_ALGORITHM == 'argon2': + return _argon2_hasher.hash(password) + # bcrypt default return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') def validate_password(password: str) -> bool: - # The password passed to bcrypt must be 72 bytes or fewer. If it is longer, it will be truncated before hashing. - if len(password.encode('utf-8')) > 72: - raise Exception( - ERROR_MESSAGES.PASSWORD_TOO_LONG, - ) + # bcrypt silently truncates passwords longer than 72 bytes; reject them explicitly. + if PASSWORD_HASH_ALGORITHM == 'bcrypt' and len(password.encode('utf-8')) > 72: + raise Exception(ERROR_MESSAGES.PASSWORD_TOO_LONG) if ENABLE_PASSWORD_VALIDATION: if not PASSWORD_VALIDATION_REGEX_PATTERN.match(password): @@ -177,14 +184,18 @@ def validate_password(password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool: - """Verify a password against its hash""" - return ( - bcrypt.checkpw( - plain_password.encode('utf-8'), - hashed_password.encode('utf-8'), - ) - if hashed_password - else None + """Verify a password against its hash, auto-detecting bcrypt or argon2.""" + if not hashed_password: + return False + if hashed_password.startswith('$argon2'): + try: + return _argon2_hasher.verify(hashed_password, plain_password) + except (VerifyMismatchError, VerificationError, InvalidHashError): + return False + # Fall back to bcrypt for existing hashes + return bcrypt.checkpw( + plain_password.encode('utf-8'), + hashed_password.encode('utf-8'), )