{"id":"GHSA-q747-mqgr-4gh7","kind":"vulnerability","aliases":["GHSA-q747-mqgr-4gh7"],"related":[],"title":"better-auth: Phone numbers can be claimed before they are verified","description":"## Am I affected?\n\nYou are affected if you use the phone number plugin from `better-auth` 0.4.12-beta.7 or later.\n\nThe shared code problem below affects 1.2.6-beta.8 and later, when you have not set a custom `verifyOTP` function.\n\n## Summary\n\n**Unverified numbers.** Sign-up and other flows could save a phone number on an account before anyone proved they own it. Phone sign-in then picked whichever account held the number. A one-time code (OTP) proves who controls the phone, not who owns that account.\n\n**Shared codes.** Phone verification codes were stored next to codes from other flows without a separate namespace. Wrong phone attempts could delete a pending code that another flow issued to a different user.\n\n## Impact\n\nAn attacker can save a victim's phone number on an account the attacker controls. When the victim later signs in with a valid OTP sent to their own phone, the victim gets a session for the attacker's account. Data the victim then enters may become available to the attacker. The attacker does not intercept or bypass the OTP, and does not get into the victim's own account.\n\nWith the shared code problem, anyone who can predict a pending code's record identifier can cancel it repeatedly. This blocks that user's sign-in or recovery attempt. It does not give access to the account.\n\n## Patches\n\nUpgrade `better-auth` to 1.7.16, or to 1.8.0-beta.5 on the beta line.\n\n1.7.16 stops new unverified numbers. Sign-up and sign-in requests that send a `phoneNumber` are rejected, and a number from a social profile is not stored, including on account linking. 1.8.0-beta.5 also requires proof of account ownership before a pending number is verified, which covers numbers saved before the upgrade.\n\nOn 1.7.16, clear numbers saved before the upgrade as shown in Workarounds.\n\n## Workarounds\n\nClear phone numbers that were never verified. For example, on PostgreSQL with the default schema names:\n\n```sql\nUPDATE \"user\" SET \"phoneNumber\" = NULL\nWHERE \"phoneNumber\" IS NOT NULL AND \"phoneNumberVerified\" IS NOT TRUE;\n```\n\nUsers who had not finished verification need to add their number again.\n\nUntil you upgrade, stop public flows from saving `phoneNumber` before verification, for example by removing it from sign-up request bodies. A strict `phoneNumberValidator` reduces the shared code problem but does not fix it.\n\n## Technical details\n\nPhone OTP sign-in and verification looked up the account by `phoneNumber` without requiring `phoneNumberVerified`. 1.7.16 rejects a `phoneNumber` in sign-up and sign-in request bodies, drops an unverified number from `createUser` data and social profiles, and drops an unverified number written during a request outside the plugin's verification path and the admin update route. Verification codes now use a phone-specific identifier. With `requireVerification`, phone password reset now also needs a verified number.","summary":"The better-auth phone number plugin saved phone numbers on accounts before the owner proved control of them, and phone sign-in then picked whichever account held the number. An attacker can attach a victim's number to an attacker-controlled account so the victim's later valid OTP sign-in yields a session for the attacker's account, and shared verification codes let an attacker cancel another user's pending code.","severity":"high","cvss":8.1,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"better-auth","fixed":["1.7.16"],"affected":">= 0.4.12-beta.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":["better-auth","cwe-287","cwe-345","cwe-706","node","package","phone-auth","typescript"],"not_affected_if":["custom-verifyotp-for-shared-codes","no-phone-plugin"],"how_to_check":"- Check the installed version with `npm ls better-auth`; versions at or above `0.4.12-beta.7` and below `1.7.16` (or `1.8.0-beta.0` to below `1.8.0-beta.5`) are affected.\n- Check whether the phone number plugin is enabled in your auth configuration.\n- Check whether your `user` table has rows where `phoneNumber` is set but `phoneNumberVerified` is not true.","fix":"- `better-auth` `1.7.16` on the 1.7 line\n- `better-auth` `1.8.0-beta.5` on the beta line\n- Clearing unverified numbers as shown in the advisory's workarounds is also described for 1.7.16.","status":"open","published_at":"2026-11-03T21:37:45+00:00","first_seen_at":"2026-11-09T22:53:20+00:00","triaged_at":"2026-11-09T22:54:15+00:00","updated_at":"2026-11-09T22:54:15+00:00","category":"package","confidence":0.85,"triage":{"at":"2026-11-09T22:54:15Z","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 beta line\n- Clearing unverified numbers as shown in the advisory's workarounds is also described for 1.7.16.","reach":"internet","title":"better-auth: phone numbers claimable before verification, letting attackers take sessions","impact":"high","summary":"The better-auth phone number plugin saved phone numbers on accounts before the owner proved control of them, and phone sign-in then picked whichever account held the number. An attacker can attach a victim's number to an attacker-controlled account so the victim's later valid OTP sign-in yields a session for the attacker's account, and shared verification codes let an attacker cancel another user's pending code.","audience":"developers","category":"package","nicknames":[],"who_cares":["better-auth","phone-auth","node","typescript"],"confidence":0.85,"fix_status":"available","needs_auth":"no","worst_case":"attacker obtains a victim's session on the attacker's account","analyst_note":"","how_to_check":"- Check the installed version with `npm ls better-auth`; versions at or above `0.4.12-beta.7` and below `1.7.16` (or `1.8.0-beta.0` to below `1.8.0-beta.5`) are affected.\n- Check whether the phone number plugin is enabled in your auth configuration.\n- Check whether your `user` table has rows where `phoneNumber` is set but `phoneNumberVerified` is not true.","plain_action":"","product_name":"","needs_analyst":false,"not_affected_if":["no-phone-plugin","custom-verifyotp-for-shared-codes"],"who_is_affected":"Applications that use the better-auth phone number plugin are exposed, since sign-up and sign-in flows can store a phone number before it is verified. The shared verification code problem also applies to apps using the default `verifyOTP` (from `1.2.6-beta.8`), while apps with a custom `verifyOTP` function are not affected by that part. Apps that do not use the phone number plugin can skip this.","user_interaction":"no","product_ecosystem":"","needs_special_setup":"yes","severity_if_unknown":"high"},"cost_usd":0.000937,"input_tokens":5570,"output_tokens":760,"severity_before":"high"},"analyst_status":null,"analyst_note":null,"search":"'-287':33B '-345':35B '-706':37B '0.4.12':137C '1.2.6':147C '1.7.16':358C,367C,422C,531C '1.8.0':361C,400C '4gh7':17A,22A 'access':348C 'account':55B,71B,88B,106B,176C,190C,209C,255C,283C,313C,351C,398C,406C,525C 'add':474C 'admin':572C 'affect':122C,125C,146C 'also':402C,590C 'anoth':115B,236C 'anyon':178C,319C 'attach':78B 'attack':76B,86B,104B,113B,245C,257C,281C,294C,296C 'attacker-control':85B 'attempt':229C,343C 'auth':3A,25A,28A,31B,42B,47B,136C,356C 'avail':291C 'becom':290C 'beta':365C 'beta.5':362C,401C 'beta.7':138C 'beta.8':148C 'better':2A,24A,27A,30B,46B,135C,355C 'better-auth':1A,23A,26A,29B,45B,134C,354C 'block':334C 'bodi':499C,544C 'bypass':301C 'cancel':114B,330C 'claim':8A 'clear':423C,434C 'code':110B,119B,143C,198C,211C,214C,219C,234C,317C,325C,506C,576C 'control':60B,87B,202C,258C 'could':169C,230C 'cover':415C 'createus':550C 'custom':157C 'cwe':32B,34B,36B 'data':284C,551C 'default':447C 'delet':231C 'detail':514C 'differ':241C 'drop':545C,556C 'enter':288C 'exampl':442C,490C 'finish':470C 'fix':511C 'flow':168C,222C,237C,483C 'function':159C 'get':276C,307C 'ghsa':14A,19A 'ghsa-q747-mqgr-4gh7':13A,18A 'give':347C 'held':72B,191C 'identifi':328C,583C 'impact':243C 'includ':396C 'intercept':299C 'issu':238C 'later':93B,140C,150C,262C 'let':111B 'line':366C 'link':399C 'look':522C 'may':289C 'mqgr':16A,21A 'name':449C 'namespac':226C 'need':472C,591C 'never':439C 'new':369C 'next':217C 'node':38B 'null':455C,460C 'number':5A,49B,53B,74B,82B,131C,162C,173C,193C,252C,371C,388C,411C,416C,424C,436C,476C,548C,559C,594C 'one':196C 'one-tim':195C 'otp':95B,199C,268C,303C,516C 'outsid':564C 'own':207C 'owner':58B 'ownership':407C 'packag':39B 'password':587C 'patch':352C 'path':569C 'pend':118B,233C,324C,410C 'phone':4A,41B,48B,52B,64B,130C,172C,183C,204C,212C,228C,251C,273C,435C,515C,581C,586C 'phone-auth':40B 'phone-specif':580C 'phonenumb':383C,454C,457C,486C,527C,534C 'phonenumbervalid':502C 'phonenumberverifi':462C,530C 'pick':69B,188C 'plugin':50B,132C,566C 'postgresql':444C 'predict':322C 'problem':144C,318C,507C 'profil':392C,554C 'proof':404C 'prove':59B,179C,200C 'public':482C 'q747':15A,20A 'record':327C 'recoveri':342C 'reduc':503C 'reject':385C,532C 'remov':492C 'repeat':332C 'request':379C,498C,543C,563C 'requir':403C,529C 'requireverif':585C 'reset':588C 'rout':574C 'save':51B,170C,247C,417C,425C,485C 'schema':448C 'send':381C 'sent':269C 'separ':225C 'session':101B,278C 'set':155C,453C 'share':108B,142C,210C,316C,505C 'shown':430C 'sign':66B,97B,164C,185C,263C,339C,373C,377C,496C,518C,537C,541C 'sign-in':65B,96B,184C,338C,376C,517C,540C 'sign-up':163C,372C,495C,536C 'social':391C,553C 'specif':582C 'sql':450C 'stop':368C,481C 'store':216C,395C 'strict':501C 'summari':160C 'technic':513C 'time':197C 'true':465C 'typescript':43B 'unverifi':161C,370C,547C,558C 'updat':451C,573C 'upgrad':353C,420C,428C,480C 'use':128C,578C 'user':116B,242C,336C,452C,466C 'valid':94B,267C 'verif':109B,213C,471C,488C,521C,568C,575C 'verifi':12A,413C,440C,593C 'verifyotp':158C 'victim':80B,91B,249C,261C,275C,286C,310C 'whichev':70B,189C 'without':223C,528C 'workaround':432C,433C 'written':560C 'wrong':227C 'yield':99B","nicknames":[],"mitigations":[],"who_affected":"Applications that use the better-auth phone number plugin are exposed, since sign-up and sign-in flows can store a phone number before it is verified. The shared verification code problem also applies to apps using the default `verifyOTP` (from `1.2.6-beta.8`), while apps with a custom `verifyOTP` function are not affected by that part. Apps that do not use the phone number plugin can skip this.","tier":"B","verified":{},"analyst_revisit_at":null,"fix_status":"available","audience":"developers","plain_action":null,"pkeys":["betterauth"],"ptokens":["auth","better"],"labels":["betterauth","cwe287","cwe345","cwe706","high","node","npm","package","phoneauth","typescript"],"campaign_type":null,"search_a":"'4gh7':17A,22A 'auth':3A,25A,28A 'better':2A,24A,27A 'better-auth':1A,23A,26A 'claim':8A 'ghsa':14A,19A 'ghsa-q747-mqgr-4gh7':13A,18A 'mqgr':16A,21A 'number':5A 'phone':4A 'q747':15A,20A 'verifi':12A","search_b":"'-287':33B '-345':35B '-706':37B 'account':55B,71B,88B,106B 'anoth':115B 'attach':78B 'attack':76B,86B,104B,113B 'attacker-control':85B 'auth':31B,42B,47B 'better':30B,46B 'better-auth':29B,45B 'cancel':114B 'code':110B,119B 'control':60B,87B 'cwe':32B,34B,36B 'held':72B 'later':93B 'let':111B 'node':38B 'number':49B,53B,74B,82B 'otp':95B 'owner':58B 'packag':39B 'pend':118B 'phone':41B,48B,52B,64B 'phone-auth':40B 'pick':69B 'plugin':50B 'prove':59B 'save':51B 'session':101B 'share':108B 'sign':66B,97B 'sign-in':65B,96B 'typescript':43B 'user':116B 'valid':94B 'verif':109B 'victim':80B,91B 'whichev':70B 'yield':99B","search_c":"'0.4.12':137C '1.2.6':147C '1.7.16':358C,367C,422C,531C '1.8.0':361C,400C 'access':348C 'account':176C,190C,209C,255C,283C,313C,351C,398C,406C,525C 'add':474C 'admin':572C 'affect':122C,125C,146C 'also':402C,590C 'anoth':236C 'anyon':178C,319C 'attack':245C,257C,281C,294C,296C 'attempt':229C,343C 'auth':136C,356C 'avail':291C 'becom':290C 'beta':365C 'beta.5':362C,401C 'beta.7':138C 'beta.8':148C 'better':135C,355C 'better-auth':134C,354C 'block':334C 'bodi':499C,544C 'bypass':301C 'cancel':330C 'clear':423C,434C 'code':143C,198C,211C,214C,219C,234C,317C,325C,506C,576C 'control':202C,258C 'could':169C,230C 'cover':415C 'createus':550C 'custom':157C 'data':284C,551C 'default':447C 'delet':231C 'detail':514C 'differ':241C 'drop':545C,556C 'enter':288C 'exampl':442C,490C 'finish':470C 'fix':511C 'flow':168C,222C,237C,483C 'function':159C 'get':276C,307C 'give':347C 'held':191C 'identifi':328C,583C 'impact':243C 'includ':396C 'intercept':299C 'issu':238C 'later':140C,150C,262C 'line':366C 'link':399C 'look':522C 'may':289C 'name':449C 'namespac':226C 'need':472C,591C 'never':439C 'new':369C 'next':217C 'null':455C,460C 'number':131C,162C,173C,193C,252C,371C,388C,411C,416C,424C,436C,476C,548C,559C,594C 'one':196C 'one-tim':195C 'otp':199C,268C,303C,516C 'outsid':564C 'own':207C 'ownership':407C 'password':587C 'patch':352C 'path':569C 'pend':233C,324C,410C 'phone':130C,172C,183C,204C,212C,228C,251C,273C,435C,515C,581C,586C 'phone-specif':580C 'phonenumb':383C,454C,457C,486C,527C,534C 'phonenumbervalid':502C 'phonenumberverifi':462C,530C 'pick':188C 'plugin':132C,566C 'postgresql':444C 'predict':322C 'problem':144C,318C,507C 'profil':392C,554C 'proof':404C 'prove':179C,200C 'public':482C 'record':327C 'recoveri':342C 'reduc':503C 'reject':385C,532C 'remov':492C 'repeat':332C 'request':379C,498C,543C,563C 'requir':403C,529C 'requireverif':585C 'reset':588C 'rout':574C 'save':170C,247C,417C,425C,485C 'schema':448C 'send':381C 'sent':269C 'separ':225C 'session':278C 'set':155C,453C 'share':142C,210C,316C,505C 'shown':430C 'sign':164C,185C,263C,339C,373C,377C,496C,518C,537C,541C 'sign-in':184C,338C,376C,517C,540C 'sign-up':163C,372C,495C,536C 'social':391C,553C 'specif':582C 'sql':450C 'stop':368C,481C 'store':216C,395C 'strict':501C 'summari':160C 'technic':513C 'time':197C 'true':465C 'unverifi':161C,370C,547C,558C 'updat':451C,573C 'upgrad':353C,420C,428C,480C 'use':128C,578C 'user':242C,336C,452C,466C 'valid':267C 'verif':213C,471C,488C,521C,568C,575C 'verifi':413C,440C,593C 'verifyotp':158C 'victim':249C,261C,275C,286C,310C 'whichev':189C 'without':223C,528C 'workaround':432C,433C 'written':560C 'wrong':227C","cause_entry":null,"cause_path":null,"disclosures_checked_at":null,"first_public_at":"2026-11-03T21:37:45+00:00","kev":null,"cvss_vector":null,"cvss_by":null,"poc_count":0,"sources":[{"source":"ghsa-repo","external_id":"GHSA-q747-mqgr-4gh7","url":"https://github.com/better-auth/better-auth/security/advisories/GHSA-q747-mqgr-4gh7","severity":"high","published_at":"2026-11-03T21:37:45+00:00","modified_at":"2026-11-03T21:37:45+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":110505,"url":"https://github.com/better-auth/better-auth/security/advisories/GHSA-q747-mqgr-4gh7","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"}