[{"data":1,"prerenderedAt":658},["ShallowReactive",2],{"blog-vulnerabilities/better-auth-sso-domain-capture":3},{"id":4,"title":5,"body":6,"category":626,"date":627,"dateModified":627,"description":628,"draft":629,"extension":630,"faq":631,"featured":629,"headerVariant":642,"image":643,"keywords":644,"meta":645,"navigation":646,"ogDescription":647,"ogTitle":643,"path":648,"readTime":649,"schemaOrg":650,"schemaType":651,"seo":652,"sitemap":653,"stem":654,"tags":655,"twitterCard":656,"__hash__":657},"blog/blog/vulnerabilities/better-auth-sso-domain-capture.md","Better Auth SSO Added Users to the Wrong Organization (2026)",{"type":7,"value":8,"toc":610},"minimark",[9,26,29,50,55,61,75,92,99,120,124,127,134,143,156,162,165,169,183,186,327,334,338,364,380,396,409,413,515,518,580,599],[10,11,12,13,20,21,25],"p",{},"On 11 August 2026 Better Auth published ",[14,15,19],"a",{"href":16,"rel":17},"https://github.com/better-auth/better-auth/security/advisories/GHSA-8c5h-wx78-2cfg",[18],"nofollow","GHSA-8c5h-wx78-2cfg",", a CVSS 8.1 advisory against ",[22,23,24],"code",{},"@better-auth/sso",". The summary is one sentence: an organization owner could register an SSO provider for a domain they did not own, and users with that email domain got quietly added to their organization.",[10,27,28],{},"We pulled both builds off npm and diffed them. The advisory is accurate, and it undersells two things: what triggers the assignment, and how little configuration you needed to be exposed.",[30,31,32],"tldr",{},[10,33,34,36,37,41,42,45,46,49],{},[22,35,24],{}," at 1.6.26 and earlier looked up organization membership by email domain and only required that domain to be verified ",[38,39,40],"strong",{},"if you had turned domain verification on",". It is off by default. The hook fired on ",[22,43,44],{},"/callback/",", Better Auth's social sign-in path, so a plain Google login was enough. Upgrade to 1.6.27, then audit your ",[22,47,48],{},"member"," table, because the patch does not undo memberships already written.",[51,52,54],"h2",{"id":53},"the-one-line-that-mattered","The one line that mattered",[10,56,57,58,60],{},"Here is the provider lookup from ",[22,59,24],{}," 1.6.26, taken from the published bundle:",[62,63,65],"code-block",{"label":64},"1.6.26: dist/index.mjs, assignOrganizationByDomain",[66,67,72],"pre",{"className":68,"code":70,"language":71},[69],"language-text","const domain = user.email.split(\"@\")[1];\nif (!domain) return;\nconst whereClause = [{ field: \"domain\", value: domain }];\nif (domainVerification?.enabled) whereClause.push({\n  field: \"domainVerified\",\n  value: true\n});\nlet ssoProvider = await ctx.context.adapter.findOne({\n  model: \"ssoProvider\",\n  where: whereClause\n});\n","text",[22,73,70],{"__ignoreMap":74},"",[10,76,77,78,81,82,85,86,91],{},"The ",[22,79,80],{},"domainVerified"," filter is conditional. It is pushed onto the query only when ",[22,83,84],{},"domainVerification.enabled"," is true, and Better Auth's ",[14,87,90],{"href":88,"rel":89},"https://www.better-auth.com/docs/plugins/sso",[18],"SSO documentation"," says that flag \"is disabled by default and must be explicitly enabled.\"",[10,93,94,95,98],{},"So on a stock configuration the query reduces to \"find me any SSO provider whose ",[22,96,97],{},"domain"," column equals this user's email domain.\" Nobody checks who put that row there.",[100,101,102],"danger-box",{},[10,103,104,105,107,108,111,112,115,116,119],{},"Registering an SSO provider is an authenticated action available to an organization owner. On the vulnerable versions, the ",[22,106,97],{}," value on that provider was accepted as a claim, not a proof. Write ",[22,109,110],{},"acme.com"," into it and every user whose email ends in ",[22,113,114],{},"@acme.com"," becomes a member of your organization at ",[22,117,118],{},"defaultRole: \"member\"",".",[51,121,123],{"id":122},"it-fired-on-google-sign-in-not-just-sso","It fired on Google sign-in, not just SSO",[10,125,126],{},"This is the finding that changed how serious we think this is, and it comes from Better Auth's own code rather than from the advisory.",[10,128,129,130,133],{},"The function is invoked from an ",[22,131,132],{},"after"," middleware with this matcher:",[62,135,137],{"label":136},"1.6.26: where the hook is registered",[66,138,141],{"className":139,"code":140,"language":71},[69],"after: [{\n  matcher(context) {\n    return context.path?.startsWith(\"/callback/\") ?? false;\n  },\n  handler: createAuthMiddleware(async (ctx) => {\n    const newSession = ctx.context.newSession;\n    if (!newSession?.user) return;\n    if (!ctx.context.hasPlugin(\"organization\")) return;\n    await assignOrganizationByDomain(ctx, {\n      user: newSession.user,\n      provisioningOptions: options?.organizationProvisioning,\n      domainVerification: options?.domainVerification\n    });\n  })\n}]\n",[22,142,140],{"__ignoreMap":74},[10,144,145,147,148,151,152,155],{},[22,146,44],{}," is Better Auth's ",[38,149,150],{},"social provider"," callback, not ",[22,153,154],{},"/sso/callback/",". The library's own docstring on the function says so in plain language:",[157,158,159],"blockquote",{},[10,160,161],{},"This enables domain-based org assignment for non-SSO sign-in methods (e.g., Google OAuth with @acme.com email gets added to Acme's org).",[10,163,164],{},"That is a documented feature, not an accident. The bug is that the feature trusted an unverified claim. So the population at risk was never \"our SSO users.\" It was every user who signed in with Google or GitHub while the SSO plugin and the organization plugin were both installed.",[51,166,168],{"id":167},"you-did-not-have-to-configure-anything","You did not have to configure anything",[10,170,171,172,175,176,179,180,119],{},"Look at what the handler passes down: ",[22,173,174],{},"provisioningOptions: options?.organizationProvisioning",". If you never configured ",[22,177,178],{},"organizationProvisioning",", that value is ",[22,181,182],{},"undefined",[10,184,185],{},"Now look at the first guard inside the vulnerable function:",[62,187,189,195,208,224,228,234,311,315,319,323],{"label":188},"1.6.26: the opt-out that was never an opt-in",[66,190,193],{"className":191,"code":192,"language":71},[69],"if (provisioningOptions?.disabled) return;\nif (!ctx.context.hasPlugin(\"organization\")) return;\n",[22,194,192],{"__ignoreMap":74},[10,196,197,200,201,203,204,207],{},[22,198,199],{},"undefined?.disabled"," is ",[22,202,182],{},", which is falsy, so the function does not return. Org provisioning was ",[38,205,206],{},"on unless you turned it off",". Installing two plugins was the entire setup.",[209,210,211],"warning-box",{},[10,212,213,214,216,217,220,221,223],{},"The three conditions for exposure were: ",[22,215,24],{}," at 1.6.26 or below, the ",[22,218,219],{},"organization"," plugin present, and a user signing in through a social provider. No SSO configuration, no ",[22,222,178],{}," block, and no domain verification setting were required.",[51,225,227],{"id":226},"one-provider-row-could-claim-several-companies","One provider row could claim several companies",[10,229,230,231,233],{},"A detail worth knowing if you are auditing rather than just upgrading. The ",[22,232,97],{}," field is parsed as a comma-separated list:",[62,235,237,243,257,287,291,302],{"label":236},"parseProviderDomains, present in both versions",[66,238,241],{"className":239,"code":240,"language":71},[69],"const entries = domain.split(\",\").map((entry) => entry.trim()).filter(Boolean);\n",[22,242,240],{"__ignoreMap":74},[10,244,245,246,249,250,253,254,256],{},"A single provider registration carrying ",[22,247,248],{},"acme.com,globex.com,initech.com"," claims all three at once. When you audit your ",[22,251,252],{},"ssoProvider"," table, read the whole ",[22,255,97],{}," string rather than assuming one row means one domain.",[10,258,259,260,263,264,267,268,271,272,275,276,279,280,283,284,286],{},"The 1.6.26 domain extraction is also worth a glance. ",[22,261,262],{},"user.email.split(\"@\")[1]"," takes the second element of the split, so it is only correct for addresses with exactly one ",[22,265,266],{},"@",". The fixed version replaces it with a ",[22,269,270],{},"getEmailDomain"," helper that rejects anything that does not split into exactly two non-empty parts, and also rejects domains containing ",[22,273,274],{},"/",", ",[22,277,278],{},"\\"," or ",[22,281,282],{},":",". We did not find a way to get a multi-",[22,285,266],{}," address past Better Auth's own validation, so treat that one as hardening rather than a second exploitable path.",[51,288,290],{"id":289},"what-1627-actually-changed","What 1.6.27 actually changed",[10,292,293,294,297,298,301],{},"The patch replaces ",[22,295,296],{},"assignOrganizationByDomain"," with a rewritten ",[22,299,300],{},"assignOrganization"," that returns a named outcome instead of falling through silently. For the domain-match path it added five checks that were not there before:",[303,304,306],"checklist-section",{"title":305},"Guards added in 1.6.27",[307,308],"checklist-item",{"description":309,"label":310},"The domain-match path returns 'provisioning-disabled' immediately unless domainVerification.enabled is true. The default now refuses to assign rather than assigning without proof.","Domain verification is now required, not optional",[307,312],{"description":313,"label":314},"findVerifiedDomainProviders filters on domainVerified: true unconditionally, so the filter no longer depends on your config.","Only verified providers are queried",[307,316],{"description":317,"label":318},"It re-reads the canonical user from the database and returns 'unverified-identity' if emailVerified is false, so an unconfirmed address cannot pull you into an org.","The user's email must be verified",[307,320],{"description":321,"label":322},"If a verified domain maps to more than one organization it logs a warning and returns 'ambiguous-target' instead of picking one.","Ambiguous domains are refused",[307,324],{"description":325,"label":326},"If an invitation to that org is already pending for the address, it returns 'invitation-pending' rather than silently creating the membership.","Pending invitations win",[10,328,329,330,333],{},"The re-read of the canonical user matters more than it looks. The old code trusted the ",[22,331,332],{},"user"," object handed to it by the session; the new code fetches the row by id before making any decision.",[51,335,337],{"id":336},"check-yours","Check yours",[339,340,342,347,353],"step",{"number":341},"1",[343,344,346],"h3",{"id":345},"get-the-resolved-version-not-the-range","Get the resolved version, not the range",[66,348,351],{"className":349,"code":350,"language":71},[69],"npm ls @better-auth/sso\n",[22,352,350],{"__ignoreMap":74},[10,354,355,356,359,360,363],{},"Read the version npm prints rather than the caret range in ",[22,357,358],{},"package.json",". A stale lockfile is the usual reason a project on ",[22,361,362],{},"^1.6.0"," is still resolving something from July.",[339,365,367,371,377],{"number":366},"2",[343,368,370],{"id":369},"upgrade","Upgrade",[66,372,375],{"className":373,"code":374,"language":71},[69],"npm install @better-auth/sso@^1.6.27\n",[22,376,374],{"__ignoreMap":74},[10,378,379],{},"On the 1.4 line the fixed release is 1.4.8. On the 1.7 prereleases it is 1.7.0-rc.5.",[339,381,383,387],{"number":382},"3",[343,384,386],{"id":385},"audit-the-provider-table","Audit the provider table",[10,388,389,390,392,393,395],{},"Look at every row in ",[22,391,252],{}," and confirm you recognise the ",[22,394,97],{}," value, remembering it may hold a comma-separated list. Any domain your team did not register is the thing this advisory is about.",[339,397,399,403],{"number":398},"4",[343,400,402],{"id":401},"audit-memberships","Audit memberships",[10,404,405,406,408],{},"Upgrading changes nothing about rows already written. Cross-check ",[22,407,48],{}," against your invitation records and look for users who hold membership without ever having accepted one.",[51,410,412],{"id":411},"sources","Sources",[414,415,416,432],"table",{},[417,418,419],"thead",{},[420,421,422,426,429],"tr",{},[423,424,425],"th",{},"Claim",[423,427,428],{},"Source",[423,430,431],{},"Date",[433,434,435,449,464,478,497],"tbody",{},[420,436,437,441,446],{},[438,439,440],"td",{},"Advisory, CVSS 8.1, affected and patched ranges",[438,442,443],{},[14,444,19],{"href":16,"rel":445},[18],[438,447,448],{},"2026-08-11",[420,450,451,454,461],{},[438,452,453],{},"CVE id and CVSS 4.0 score of 8.6",[438,455,456],{},[14,457,460],{"href":458,"rel":459},"https://github.com/advisories/GHSA-xfj7-7fp8-rhvp",[18],"GHSA-xfj7-7fp8-rhvp / CVE-2026-80192",[438,462,463],{},"NVD 2026-08-26, GitHub DB 2026-08-29",[420,465,466,469,475],{},[438,467,468],{},"Domain verification is disabled by default",[438,470,471],{},[14,472,474],{"href":88,"rel":473},[18],"Better Auth SSO plugin docs",[438,476,477],{},"Retrieved 2026-09-10",[420,479,480,486,494],{},[438,481,482,483],{},"Vulnerable lookup, hook matcher, docstring, ",[22,484,485],{},"split(\"@\")[1]",[438,487,488,490,491],{},[22,489,24],{}," 1.6.26, ",[22,492,493],{},"dist/index.mjs",[438,495,496],{},"npm published 2026-08-04",[420,498,499,505,512],{},[438,500,501,502,504],{},"Rewritten ",[22,503,300],{}," and its five guards",[438,506,507,509,510],{},[22,508,24],{}," 1.6.27, ",[22,511,493],{},[438,513,514],{},"npm published 2026-08-11",[10,516,517],{},"Both package builds were downloaded from the npm registry and diffed on 2026-09-10. Every code excerpt above is quoted from the published bundle rather than from the repository, so it is the code that actually shipped.",[519,520,521,534,543,555,568],"faq-section",{},[522,523,525],"faq-item",{"question":524},"Which versions of Better Auth SSO are affected?",[10,526,77,527,529,530,533],{},[22,528,24],{}," package is affected from 1.4.8-beta.1 up to and including 1.6.26, and in the 1.7 prerelease line from 1.7.0-beta.0 through 1.7.0-rc.4. Fixed releases are 1.4.8, 1.6.27 and 1.7.0-rc.5. Core ",[22,531,532],{},"better-auth"," is a separate package and is not what this advisory covers, so check the SSO plugin's own version rather than your main auth dependency.",[522,535,537],{"question":536},"Am I affected if my users do not use SSO?",[10,538,539,540,542],{},"Possibly yes, and this is the part the advisory text does not spell out. In 1.6.26 the org-assignment hook ran on paths starting with ",[22,541,44],{},", which is Better Auth's social sign-in callback rather than the SSO callback. Installing the SSO plugin alongside the organization plugin was enough for an ordinary Google or GitHub sign-in to trigger domain-based org assignment.",[522,544,546],{"question":545},"Is domain verification on by default in Better Auth?",[10,547,548,549,551,552,554],{},"No. Better Auth's SSO documentation states that domain verification is disabled by default and must be explicitly enabled with ",[22,550,84],{}," set to true. In the vulnerable versions that default was what removed the ",[22,553,80],{}," filter from the provider lookup, so the shipped default was the unsafe path.",[522,556,558],{"question":557},"How do I check which version I have?",[10,559,560,561,564,565,567],{},"Run ",[22,562,563],{},"npm ls @better-auth/sso"," to see the resolved version rather than the range in your ",[22,566,358],{},", because a caret range can resolve to an old version from a stale lockfile. If it prints anything at or below 1.6.26, upgrade to 1.6.27 and then audit existing organization memberships, since upgrading does not remove memberships that were already created.",[522,569,571],{"question":570},"Does upgrading undo memberships that were already created?",[10,572,573,574,576,577,579],{},"No. The patch changes how future assignments are made and does nothing to rows already written to your ",[22,575,48],{}," table. If you ran an affected version with the organization plugin installed, review existing memberships for users who never accepted an invitation, and check your ",[22,578,252],{}," table for domain values nobody on your team registered.",[581,582,583,589,594],"related-articles",{},[584,585],"related-card",{"description":586,"href":587,"title":588},"The earlier pair of advisories, and the version check that catches both","/blog/vulnerabilities/better-auth-pre-account-hijacking","Better Auth Account Takeover",[584,590],{"description":591,"href":592,"title":593},"Where org boundaries break outside the database layer","/blog/checklists/saas-security-checklist","Multi-Tenant SaaS Security Checklist",[584,595],{"description":596,"href":597,"title":598},"The same shape in a different library: a default that fails open","/blog/vulnerabilities/next-auth-v5-fail-open","next-auth v5 Auth Bypass",[600,601,603,607],"cta-box",{"href":274,"label":602},"Start Free Scan",[51,604,606],{"id":605},"check-what-your-auth-actually-allows","Check What Your Auth Actually Allows",[10,608,609],{},"A free CheckYourVibe scan looks for the exposure patterns AI-generated auth code leaves behind.",{"title":74,"searchDepth":611,"depth":611,"links":612},2,[613,614,615,616,617,624,625],{"id":53,"depth":611,"text":54},{"id":122,"depth":611,"text":123},{"id":167,"depth":611,"text":168},{"id":226,"depth":611,"text":227},{"id":336,"depth":611,"text":337,"children":618},[619,621,622,623],{"id":345,"depth":620,"text":346},3,{"id":369,"depth":620,"text":370},{"id":385,"depth":620,"text":386},{"id":401,"depth":620,"text":402},{"id":411,"depth":611,"text":412},{"id":605,"depth":611,"text":606},"vulnerabilities","2026-09-10","@better-auth/sso up to 1.6.26 matched org membership on an unverified domain claim, and it fired on ordinary Google sign-in. We read the shipped diff.",false,"md",[632,634,636,638,640],{"question":524,"answer":633},"The @better-auth/sso package is affected from 1.4.8-beta.1 up to and including 1.6.26, and in the 1.7 prerelease line from 1.7.0-beta.0 through 1.7.0-rc.4. Fixed releases are 1.4.8, 1.6.27 and 1.7.0-rc.5. Core better-auth is a separate package and is not what this advisory covers, so check the SSO plugin's own version rather than your main auth dependency.",{"question":536,"answer":635},"Possibly yes, and this is the part the advisory text does not spell out. In 1.6.26 the org-assignment hook ran on paths starting with /callback/, which is Better Auth's social sign-in callback rather than the SSO callback. Installing the SSO plugin alongside the organization plugin was enough for an ordinary Google or GitHub sign-in to trigger domain-based org assignment.",{"question":545,"answer":637},"No. Better Auth's SSO documentation states that domain verification is disabled by default and must be explicitly enabled with domainVerification.enabled set to true. In the vulnerable versions that default was what removed the domainVerified filter from the provider lookup, so the shipped default was the unsafe path.",{"question":557,"answer":639},"Run npm ls @better-auth/sso to see the resolved version rather than the range in your package.json, because a caret range can resolve to an old version from a stale lockfile. If it prints anything at or below 1.6.26, upgrade to 1.6.27 and then audit existing organization memberships, since upgrading does not remove memberships that were already created.",{"question":570,"answer":641},"No. The patch changes how future assignments are made and does nothing to rows already written to your member table. If you ran an affected version with the organization plugin installed, review existing memberships for users who never accepted an invitation, and check your ssoProvider table for domain values nobody on your team registered.","red",null,"better auth sso vulnerability, better auth sso wrong organization, GHSA-8c5h-wx78-2cfg, CVE-2026-80192, better auth domain verification default, sso domain capture, better auth organization provisioning",{},true,"An unverified domain claim in @better-auth/sso let any organization owner capture users who signed in with Google. Here is the code that changed.","/blog/vulnerabilities/better-auth-sso-domain-capture","9 min read","[object Object]","BlogPosting",{"title":5,"description":628},{"loc":648},"blog/vulnerabilities/better-auth-sso-domain-capture",[],"summary_large_image","56NtbvUfU5A6cqHmzLPKn5kyIFqBR38sLjq61jmohZo",1789672845340]