{"id":"GHSA-2f9c-7xj7-gh55","kind":"vulnerability","aliases":["GHSA-2f9c-7xj7-gh55"],"related":[],"title":"better-auth: Email OTP attempt limit can be raced and codes are loosely bound to addresses","description":"## Am I affected?\r\n\r\nYou are affected if you use the `emailOTP()` plugin from `better-auth` 1.3.7 or later. Each problem below adds its own condition:\r\n\r\n- **Attempt limit:** any email OTP flow.\r\n- **Email change codes:** `changeEmail.enabled: true` in the plugin, from 1.5.0.\r\n- **Accented addresses:** a database collation that ignores accents, common on MySQL.\r\n\r\n## Summary\r\n\r\n**Attempt limit.** Guesses that arrive at the same time could be counted as one attempt, so an attacker could make more guesses than `allowedAttempts` allows.\r\n\r\n**Email change codes.** Codes for changing an email address were stored under a key built from both addresses. Two different address pairs could produce the same key, so a code requested from one account could be used from another.\r\n\r\n**Accented addresses.** On databases that ignore accents, a lookup for an address could return an account with a different stored address. Password reset then sent the code to the requested address, not the account's own.\r\n\r\n## Impact\r\n\r\nWith the attempt limit problem, an unauthenticated attacker can make extra guesses against an active OTP and, if a guess hits, sign in or reset the password.\r\n\r\nWith the email change problem, a signed-in user can mark an unused address on their own account as verified. The real owner of that address may later be signed in to the attacker's account.\r\n\r\nWith the accented address problem, an attacker who receives mail at an address that differs from the victim's only by accents can reset the victim's password, with no action from the victim.\r\n\r\n## Patches\r\n\r\nUpgrade `better-auth` to 1.7.16, or to 1.8.0-beta.5 on the beta line. Email change codes sent before the upgrade must be requested again.\r\n\r\n## Workarounds\r\n\r\nTurn off email change in the email OTP plugin, which is the default, and use the core email change flow instead. Lowering `allowedAttempts` and the OTP lifetime reduces the attempt limit problem but does not fix it.\r\n\r\n## Technical details\r\n\r\nThe attempt counter was read and written in separate steps, so concurrent checks could each see the same count. Every guess now counts against the limit, and a correct check still leaves the OTP usable for the follow-up call. Email change codes are now bound to the requesting account and the new address. Looking up a user by email now returns an account only when its stored address matches the requested one, ignoring case, and the reset routes send the code to the address on the account.\r\n\r\n## Credit\r\n\r\nThe accented address problem was discovered by Anthropic's OSS Scanner, as vulnerability ANT-2026-Y7X5RTID.","summary":"The emailOTP() plugin in better-auth can be made to count concurrent guesses as one attempt, letting an attacker exceed allowedAttempts and sign in or reset a password. Email change codes could also be used on a different account, and on databases that ignore accents, a reset code could go to a different address.","severity":"medium","cvss":4.8,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"better-auth","fixed":["1.7.16"],"affected":">= 1.3.7, < 1.7.16","ecosystem":"npm"},{"name":"better-auth","fixed":["1.8.0-beta.5"],"affected":">= 1.8.0-beta.0, < 1.8.0-beta.5","ecosystem":"npm"}],"ecosystems":["npm"],"tags":["authentication","better-auth","cwe-307","cwe-362","cwe-639","email-otp","node","package"],"not_affected_if":["changeemail-disabled","no-emailotp-plugin","non-accent-insensitive-db"],"how_to_check":"- Check the installed `better-auth` version with `npm ls better-auth`.\n- Look for `emailOTP(` in your auth config and check whether `changeEmail.enabled` is `true`.\n- Check whether your database collation ignores accents (common on MySQL).","fix":"- `better-auth` `1.7.16` on the 1.7 line\n- `better-auth` `1.8.0-beta.5` on the 1.8 beta line\n- Email change codes sent before the upgrade must be requested again.","status":"open","published_at":"2026-11-03T21:38:55+00:00","first_seen_at":"2026-11-09T22:53:20+00:00","triaged_at":"2026-11-09T22:57:06+00:00","updated_at":"2026-11-09T22:57:06+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-11-09T22:57:06Z","model":"claude-haiku-5-5","output":{"fix":"- `better-auth` `1.7.16` on the 1.7 line\n- `better-auth` `1.8.0-beta.5` on the 1.8 beta line\n- Email change codes sent before the upgrade must be requested again.","reach":"internet","title":"better-auth: email OTP attempt limit can be raced, codes loosely bound","impact":"high","summary":"The emailOTP() plugin in better-auth can be made to count concurrent guesses as one attempt, letting an attacker exceed allowedAttempts and sign in or reset a password. Email change codes could also be used on a different account, and on databases that ignore accents, a reset code could go to a different address.","audience":"developers","category":"package","nicknames":[],"who_cares":["better-auth","node","email-otp","authentication"],"confidence":0.8,"fix_status":"available","needs_auth":"unknown","worst_case":"attacker signs in or resets a password via OTP guessing","analyst_note":"","how_to_check":"- Check the installed `better-auth` version with `npm ls better-auth`.\n- Look for `emailOTP(` in your auth config and check whether `changeEmail.enabled` is `true`.\n- Check whether your database collation ignores accents (common on MySQL).","plain_action":"","product_name":"","needs_analyst":false,"not_affected_if":["no-emailotp-plugin","changeemail-disabled","non-accent-insensitive-db"],"who_is_affected":"Applications that use the emailOTP() plugin from better-auth 1.3.7 or later are exposed to the attempt-limit race. Email change codes apply only if changeEmail.enabled is true (from 1.5.0), and the accented-address issue applies only on databases whose collation ignores accents, common on MySQL. Apps that do not use emailOTP() can skip it.","user_interaction":"no","product_ecosystem":"","needs_special_setup":"yes","severity_if_unknown":"medium"},"cost_usd":0.000867,"input_tokens":5414,"output_tokens":652,"severity_before":"medium"},"analyst_status":null,"analyst_note":null,"search":"'-2026':527C '-307':39B '-362':41B '-639':43B '1.3.7':120C '1.5.0':145C '1.7.16':363C '1.8.0':366C '2f9c':20A,25A '7xj7':21A,26A 'accent':94B,146C,153C,222C,228C,325C,344C,514C 'account':88B,216C,237C,255C,304C,322C,473C,487C,511C 'action':353C 'activ':273C 'add':126C 'address':17A,103B,147C,191C,200C,203C,223C,233C,242C,252C,300C,312C,326C,335C,477C,492C,508C,515C 'affect':106C,109C 'allow':182C 'allowedattempt':70B,181C,406C 'also':82B 'anoth':221C 'ant':526C 'anthrop':520C 'arriv':162C 'attack':68B,175C,266C,320C,329C 'attempt':6A,65B,130C,158C,172C,261C,413C,424C 'auth':3A,30A,33A,37B,55B,119C,361C 'authent':34B 'beta':370C 'beta.5':367C 'better':2A,29A,32A,36B,54B,118C,360C 'better-auth':1A,28A,31A,35B,53B,117C,359C 'bound':15A,469C 'built':197C 'call':463C 'case':498C 'chang':79B,137C,184C,188C,289C,373C,387C,402C,465C 'changeemail.enabled':139C 'check':435C,452C 'code':12A,80B,97B,138C,185C,186C,212C,248C,374C,466C,505C 'collat':150C 'common':154C 'concurr':61B,434C 'condit':129C 'core':400C 'correct':451C 'could':81B,98B,167C,176C,205C,217C,234C,436C 'count':60B,169C,441C,445C 'counter':425C 'credit':512C 'cwe':38B,40B,42B 'databas':91B,149C,225C 'default':396C 'detail':422C 'differ':87B,102B,202C,240C,337C 'discov':518C 'email':4A,45B,78B,133C,136C,183C,190C,288C,372C,386C,390C,401C,464C,483C 'email-otp':44B 'emailotp':50B,114C 'everi':442C 'exceed':69B 'extra':269C 'fix':419C 'flow':135C,403C 'follow':461C 'follow-up':460C 'gh55':22A,27A 'ghsa':19A,24A 'ghsa-2f9c-7xj7-gh55':18A,23A 'go':99B 'guess':62B,160C,179C,270C,278C,443C 'hit':279C 'ignor':93B,152C,227C,497C 'impact':258C 'instead':404C 'key':196C,209C 'later':122C,314C 'leav':454C 'let':66B 'lifetim':410C 'limit':7A,131C,159C,262C,414C,448C 'line':371C 'look':478C 'lookup':230C 'loos':14A 'lower':405C 'made':58B 'mail':332C 'make':177C,268C 'mark':297C 'match':493C 'may':313C 'must':379C 'mysql':156C 'new':476C 'node':47B 'one':64B,171C,215C,496C 'oss':522C 'otp':5A,46B,134C,274C,391C,409C,456C 'owner':309C 'packag':48B 'pair':204C 'password':77B,243C,285C,350C 'patch':357C 'plugin':51B,115C,143C,392C 'problem':124C,263C,290C,327C,415C,516C 'produc':206C 'race':10A 'read':427C 'real':308C 'receiv':331C 'reduc':411C 'request':213C,251C,381C,472C,495C 'reset':75B,96B,244C,283C,346C,501C 'return':235C,485C 'rout':502C 'scanner':523C 'see':438C 'send':503C 'sent':246C,375C 'separ':431C 'sign':72B,280C,293C,316C 'signed-in':292C 'step':432C 'still':453C 'store':193C,241C,491C 'summari':157C 'technic':421C 'time':166C 'true':140C 'turn':384C 'two':201C 'unauthent':265C 'unus':299C 'upgrad':358C,378C 'usabl':457C 'use':84B,112C,219C,398C 'user':295C,481C 'verifi':306C 'victim':340C,348C,356C 'vulner':525C 'workaround':383C 'written':429C 'y7x5rtid':528C","nicknames":[],"mitigations":[],"who_affected":"Applications that use the emailOTP() plugin from better-auth 1.3.7 or later are exposed to the attempt-limit race. Email change codes apply only if changeEmail.enabled is true (from 1.5.0), and the accented-address issue applies only on databases whose collation ignores accents, common on MySQL. Apps that do not use emailOTP() can skip it.","tier":"C","verified":{},"analyst_revisit_at":null,"fix_status":"available","audience":"developers","plain_action":null,"pkeys":["betterauth"],"ptokens":["auth","better"],"labels":["authentication","betterauth","cwe307","cwe362","cwe639","emailotp","medium","node","npm","package"],"campaign_type":null,"search_a":"'2f9c':20A,25A '7xj7':21A,26A 'address':17A 'attempt':6A 'auth':3A,30A,33A 'better':2A,29A,32A 'better-auth':1A,28A,31A 'bound':15A 'code':12A 'email':4A 'gh55':22A,27A 'ghsa':19A,24A 'ghsa-2f9c-7xj7-gh55':18A,23A 'limit':7A 'loos':14A 'otp':5A 'race':10A","search_b":"'-307':39B '-362':41B '-639':43B 'accent':94B 'account':88B 'address':103B 'allowedattempt':70B 'also':82B 'attack':68B 'attempt':65B 'auth':37B,55B 'authent':34B 'better':36B,54B 'better-auth':35B,53B 'chang':79B 'code':80B,97B 'concurr':61B 'could':81B,98B 'count':60B 'cwe':38B,40B,42B 'databas':91B 'differ':87B,102B 'email':45B,78B 'email-otp':44B 'emailotp':50B 'exceed':69B 'go':99B 'guess':62B 'ignor':93B 'let':66B 'made':58B 'node':47B 'one':64B 'otp':46B 'packag':48B 'password':77B 'plugin':51B 'reset':75B,96B 'sign':72B 'use':84B","search_c":"'-2026':527C '1.3.7':120C '1.5.0':145C '1.7.16':363C '1.8.0':366C 'accent':146C,153C,222C,228C,325C,344C,514C 'account':216C,237C,255C,304C,322C,473C,487C,511C 'action':353C 'activ':273C 'add':126C 'address':147C,191C,200C,203C,223C,233C,242C,252C,300C,312C,326C,335C,477C,492C,508C,515C 'affect':106C,109C 'allow':182C 'allowedattempt':181C,406C 'anoth':221C 'ant':526C 'anthrop':520C 'arriv':162C 'attack':175C,266C,320C,329C 'attempt':130C,158C,172C,261C,413C,424C 'auth':119C,361C 'beta':370C 'beta.5':367C 'better':118C,360C 'better-auth':117C,359C 'bound':469C 'built':197C 'call':463C 'case':498C 'chang':137C,184C,188C,289C,373C,387C,402C,465C 'changeemail.enabled':139C 'check':435C,452C 'code':138C,185C,186C,212C,248C,374C,466C,505C 'collat':150C 'common':154C 'concurr':434C 'condit':129C 'core':400C 'correct':451C 'could':167C,176C,205C,217C,234C,436C 'count':169C,441C,445C 'counter':425C 'credit':512C 'databas':149C,225C 'default':396C 'detail':422C 'differ':202C,240C,337C 'discov':518C 'email':133C,136C,183C,190C,288C,372C,386C,390C,401C,464C,483C 'emailotp':114C 'everi':442C 'extra':269C 'fix':419C 'flow':135C,403C 'follow':461C 'follow-up':460C 'guess':160C,179C,270C,278C,443C 'hit':279C 'ignor':152C,227C,497C 'impact':258C 'instead':404C 'key':196C,209C 'later':122C,314C 'leav':454C 'lifetim':410C 'limit':131C,159C,262C,414C,448C 'line':371C 'look':478C 'lookup':230C 'lower':405C 'mail':332C 'make':177C,268C 'mark':297C 'match':493C 'may':313C 'must':379C 'mysql':156C 'new':476C 'one':171C,215C,496C 'oss':522C 'otp':134C,274C,391C,409C,456C 'owner':309C 'pair':204C 'password':243C,285C,350C 'patch':357C 'plugin':115C,143C,392C 'problem':124C,263C,290C,327C,415C,516C 'produc':206C 'read':427C 'real':308C 'receiv':331C 'reduc':411C 'request':213C,251C,381C,472C,495C 'reset':244C,283C,346C,501C 'return':235C,485C 'rout':502C 'scanner':523C 'see':438C 'send':503C 'sent':246C,375C 'separ':431C 'sign':280C,293C,316C 'signed-in':292C 'step':432C 'still':453C 'store':193C,241C,491C 'summari':157C 'technic':421C 'time':166C 'true':140C 'turn':384C 'two':201C 'unauthent':265C 'unus':299C 'upgrad':358C,378C 'usabl':457C 'use':112C,219C,398C 'user':295C,481C 'verifi':306C 'victim':340C,348C,356C 'vulner':525C 'workaround':383C 'written':429C 'y7x5rtid':528C","cause_entry":null,"cause_path":null,"disclosures_checked_at":null,"first_public_at":"2026-11-03T21:38:55+00:00","kev":null,"cvss_vector":null,"cvss_by":null,"poc_count":0,"sources":[{"source":"ghsa-repo","external_id":"GHSA-2f9c-7xj7-gh55","url":"https://github.com/better-auth/better-auth/security/advisories/GHSA-2f9c-7xj7-gh55","severity":"medium","published_at":"2026-11-03T21:38:55+00:00","modified_at":"2026-11-03T21:38:55+00:00","first_seen_at":"2026-11-09T22:53:20+00:00","fetched_at":"2026-11-09T22:53:20+00:00","note":null}],"documents":[{"id":110504,"url":"https://github.com/better-auth/better-auth/security/advisories/GHSA-2f9c-7xj7-gh55","title":"Maintainer's advisory: github.com","kind":"vendor","source":"ghsa-repo","publisher":"vendor","text":null,"published_at":null,"relation":"advisory","confidence":null,"note":"Maintainer's advisory: github.com"}],"disclosures":[],"exploit_sources":[],"notice":"Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai"}