[{"data":1,"prerenderedAt":1014},["ShallowReactive",2],{"blog-vulnerabilities/nextauth-magic-link-homoglyph":3},{"id":4,"title":5,"body":6,"category":982,"date":983,"dateModified":984,"description":985,"draft":986,"extension":987,"faq":988,"featured":986,"headerVariant":997,"image":998,"keywords":999,"meta":1000,"navigation":241,"ogDescription":1003,"ogTitle":998,"path":1004,"readTime":1005,"schemaOrg":1006,"schemaType":1007,"seo":1008,"sitemap":1009,"stem":1010,"tags":1011,"twitterCard":1012,"__hash__":1013},"blog/blog/vulnerabilities/nextauth-magic-link-homoglyph.md","next-auth Magic Link Misdelivery: CVE-2026-73420 Explained (2026)",{"type":7,"value":8,"toc":974},"minimark",[9,13,16,46,51,83,86,105,111,114,131,135,144,353,356,373,376,379,390,403,413,417,420,474,477,480,491,495,498,505,521,524,527,530,595,598,602,621,642,849,859,872,940,959,970],[10,11,12],"p",{},"There have now been three separate advisories about next-auth delivering a magic link to somebody who did not ask for it. The 2022 one, the 2025 one, and CVE-2026-73420 in July.",[10,14,15],{},"Here is the part worth your attention. Each fix is where the next bug lives.",[17,18,19],"tldr",{},[10,20,21,22,26,27,30,31,33,34,37,38,41,42,45],{},"Auth.js counts the ",[23,24,25],"code",{},"@"," symbols in a submitted address and rejects anything without exactly one. A fullwidth commercial at (",[23,28,29],{},"U+FF20",") is not an ASCII ",[23,32,25],{},", so it sails through that count. A mail library that then applies Unicode NFKC normalization sees two separators and can deliver the sign-in link to the attacker's domain. Patched in ",[23,35,36],{},"next-auth"," 4.24.15, ",[23,39,40],{},"5.0.0-beta.32"," and ",[23,43,44],{},"@auth/core"," 0.41.3, all published 20 July 2026. The at-sign counter it defeats was itself added in October 2025 to fix the previous misdelivery bug.",[47,48,50],"h2",{"id":49},"am-i-affected","Am I affected",[52,53,55],"code-block",{"label":54},"The version check",[56,57,62],"pre",{"className":58,"code":59,"language":60,"meta":61,"style":61},"language-bash shiki shiki-themes github-dark","npm ls next-auth @auth/core\n","bash","",[23,63,64],{"__ignoreMap":61},[65,66,69,73,77,80],"span",{"class":67,"line":68},"line",1,[65,70,72],{"class":71},"svObZ","npm",[65,74,76],{"class":75},"sU2Wk"," ls",[65,78,79],{"class":75}," next-auth",[65,81,82],{"class":75}," @auth/core\n",[10,84,85],{},"You are in the published affected ranges if you are on:",[87,88,89,95,100],"ul",{},[90,91,92,94],"li",{},[23,93,36],{}," >= 4.10.3 and \u003C 4.24.15",[90,96,97,99],{},[23,98,36],{}," >= 5.0.0-beta.1 and \u003C= 5.0.0-beta.31",[90,101,102,104],{},[23,103,44],{}," >= 0.1.0 and \u003C 0.41.3",[10,106,107,108,110],{},"That last floor is worth reading twice. ",[23,109,44],{}," 0.1.0 shipped in December 2022, so \"every version before 0.41.3\" means every version there has ever been.",[10,112,113],{},"You also have to be using the email or magic-link sign-in path. If your app is OAuth-only, this specific advisory is not yours to worry about.",[115,116,117],"info-box",{},[10,118,119,122,123,126,127,130],{},[23,120,121],{},"npm install next-auth"," today resolves to 4.24.15, and ",[23,124,125],{},"next-auth@beta"," resolves to 5.0.0-beta.32. Both are patched. This bug covered every install path right up until 20 July 2026, which is why an app scaffolded in June and never updated is the one to check. There is still no stable v5: the ",[23,128,129],{},"beta"," dist-tag is the v5 release line.",[47,132,134],{"id":133},"the-bug-precisely","The bug, precisely",[10,136,137,138,140,141,143],{},"The default normalizer trims the address, counts ",[23,139,25],{},", and refuses anything that does not have exactly one. Here it is as shipped in ",[23,142,36],{}," 4.24.12, comments included:",[52,145,147],{"label":146},"src/core/routes/signin.ts, next-auth 4.24.12",[56,148,152],{"className":149,"code":150,"language":151,"meta":61,"style":61},"language-ts shiki shiki-themes github-dark","const normalizer: (identifier: string) => string =\n  provider.normalizeIdentifier ??\n  ((identifier) => {\n    const trimmedEmail = identifier.trim()\n\n    // Validate email format according to RFC 5321/5322\n    // Reject emails with quotes in the local part to prevent address parser exploits\n    // Reject multiple @ symbols which could indicate an exploit attempt\n    const atCount = (trimmedEmail.match(/@/g) ?? []).length\n    if (atCount !== 1) {\n      throw new Error(\"Invalid email address format.\")\n    }\n    // ...\n","ts",[23,153,154,191,200,215,236,243,250,256,262,303,321,341,347],{"__ignoreMap":61},[65,155,156,160,163,166,170,174,176,180,183,186,188],{"class":67,"line":68},[65,157,159],{"class":158},"snl16","const",[65,161,162],{"class":71}," normalizer",[65,164,165],{"class":158},":",[65,167,169],{"class":168},"s95oV"," (",[65,171,173],{"class":172},"s9osk","identifier",[65,175,165],{"class":158},[65,177,179],{"class":178},"sDLfK"," string",[65,181,182],{"class":168},") ",[65,184,185],{"class":158},"=>",[65,187,179],{"class":178},[65,189,190],{"class":158}," =\n",[65,192,194,197],{"class":67,"line":193},2,[65,195,196],{"class":168},"  provider.normalizeIdentifier ",[65,198,199],{"class":158},"??\n",[65,201,203,206,208,210,212],{"class":67,"line":202},3,[65,204,205],{"class":168},"  ((",[65,207,173],{"class":172},[65,209,182],{"class":168},[65,211,185],{"class":158},[65,213,214],{"class":168}," {\n",[65,216,218,221,224,227,230,233],{"class":67,"line":217},4,[65,219,220],{"class":158},"    const",[65,222,223],{"class":178}," trimmedEmail",[65,225,226],{"class":158}," =",[65,228,229],{"class":168}," identifier.",[65,231,232],{"class":71},"trim",[65,234,235],{"class":168},"()\n",[65,237,239],{"class":67,"line":238},5,[65,240,242],{"emptyLinePlaceholder":241},true,"\n",[65,244,246],{"class":67,"line":245},6,[65,247,249],{"class":248},"sAwPA","    // Validate email format according to RFC 5321/5322\n",[65,251,253],{"class":67,"line":252},7,[65,254,255],{"class":248},"    // Reject emails with quotes in the local part to prevent address parser exploits\n",[65,257,259],{"class":67,"line":258},8,[65,260,261],{"class":248},"    // Reject multiple @ symbols which could indicate an exploit attempt\n",[65,263,265,267,270,272,275,278,281,284,287,289,292,294,297,300],{"class":67,"line":264},9,[65,266,220],{"class":158},[65,268,269],{"class":178}," atCount",[65,271,226],{"class":158},[65,273,274],{"class":168}," (trimmedEmail.",[65,276,277],{"class":71},"match",[65,279,280],{"class":168},"(",[65,282,283],{"class":75},"/",[65,285,25],{"class":286},"sns5M",[65,288,283],{"class":75},[65,290,291],{"class":158},"g",[65,293,182],{"class":168},[65,295,296],{"class":158},"??",[65,298,299],{"class":168}," []).",[65,301,302],{"class":178},"length\n",[65,304,306,309,312,315,318],{"class":67,"line":305},10,[65,307,308],{"class":158},"    if",[65,310,311],{"class":168}," (atCount ",[65,313,314],{"class":158},"!==",[65,316,317],{"class":178}," 1",[65,319,320],{"class":168},") {\n",[65,322,324,327,330,333,335,338],{"class":67,"line":323},11,[65,325,326],{"class":158},"      throw",[65,328,329],{"class":158}," new",[65,331,332],{"class":71}," Error",[65,334,280],{"class":168},[65,336,337],{"class":75},"\"Invalid email address format.\"",[65,339,340],{"class":168},")\n",[65,342,344],{"class":67,"line":343},12,[65,345,346],{"class":168},"    }\n",[65,348,350],{"class":67,"line":349},13,[65,351,352],{"class":248},"    // ...\n",[10,354,355],{},"\"Reject multiple @ symbols which could indicate an exploit attempt.\" The check does exactly that, and the attack is built so that at the moment of counting there is only one.",[10,357,358,361,362,365,366,369,370,372],{},[23,359,360],{},"U+FF20 FULLWIDTH COMMERCIAL AT"," is a different codepoint from ",[23,363,364],{},"U+0040",". The regex ",[23,367,368],{},"/@/g"," does not match it. But under NFKC normalization, the form mail libraries apply when they handle internationalized addresses, it folds into a plain ASCII ",[23,371,25],{},".",[10,374,375],{},"So the string is valid on the way in and a different string on the way out.",[10,377,378],{},"We ran the shipped normalizer against three payloads to see which survive:",[52,380,382],{"label":381},"The default normalizer from @auth/core 0.41.2, run against three payloads",[56,383,388],{"className":384,"code":386,"language":387},[385],"language-text","\"victim@example.com,attacker@evil.com\"    -> REJECTED: Invalid email address format.\n\n\"victim@example.com\u003CU+FF20>attacker.evil\"\n   accepted as -> \"victim@example.com＠attacker.evil\"\n   after NFKC   -> \"victim@example.com@attacker.evil\"   | @ count: 2\n   domain a last-@ parser delivers to -> attacker.evil\n\n\"victim@example.com\u003CU+FE6B>attacker.evil\"\n   accepted as -> \"victim@example.com﹫attacker.evil\"\n   after NFKC   -> \"victim@example.com@attacker.evil\"   | @ count: 2\n   domain a last-@ parser delivers to -> attacker.evil\n","text",[23,389,386],{"__ignoreMap":61},[10,391,392,393,395,396,399,400,402],{},"Two things fall out of that. The 2022 comma payload is genuinely dead, so the older fix works. And the advisory names ",[23,394,29],{}," as the example, but it is not the only one: ",[23,397,398],{},"U+FE6B SMALL COMMERCIAL AT"," folds to the same character and behaves identically. Anything that NFKC-folds to ",[23,401,25],{}," is a candidate.",[404,405,406],"warning-box",{},[10,407,408,409,412],{},"The victim does nothing here. The attacker submits the crafted address to your public sign-in endpoint, and the link for the victim's account is sent somewhere else. That is the ",[23,410,411],{},"UI:N"," in the CVSS 4.0 vector, and it is why the score is 9.1.",[47,414,416],{"id":415},"the-chain-which-is-the-actual-story","The chain, which is the actual story",[10,418,419],{},"We bisected the published tarballs to find where each check entered the code. The dates are the npm publish times.",[421,422,423,448,465],"timeline",{},[424,425,427],"timeline-item",{"time":426},"August 2022",[10,428,429,433,434,437,438,440,441,444,445,447],{},[430,431,432],"strong",{},"CVE-2022-35924."," Submitting ",[23,435,436],{},"victim@example.com,attacker@evil.com"," sends the sign-in mail to both addresses. Fixed in ",[23,439,36],{}," 4.10.3, which introduces the pluggable ",[23,442,443],{},"normalizeIdentifier"," hook and a default that lowercases, splits on ",[23,446,25],{},", and drops everything after a comma in the domain. There is no at-sign counter yet.",[424,449,451],{"time":450},"October 2025",[10,452,453,456,457,460,461,464],{},[430,454,455],{},"GHSA-5jpx-9hw9-2fx4",", \"NextAuthjs Email misdelivery Vulnerability\". A quoted local part, ",[23,458,459],{},"\"e@attacker.com\"@victim.com",", reaches the wrong recipient. Fixed in 4.24.12, which adds the quote rejection and the ",[23,462,463],{},"atCount !== 1"," check to that same default normalizer.",[424,466,468],{"time":467},"July 2026",[10,469,470,473],{},[430,471,472],{},"CVE-2026-73420."," A homoglyph defeats the at-sign counter added nine months earlier, because the count happens before normalization rather than after. Fixed in 4.24.15 by normalizing first.",[10,475,476],{},"Three advisories, three fixes, and the same shape each time: a string check added in front of a mail library that parses addresses by different rules. Every fix closed the exact input that got reported. None of them closed the gap between what the validator thinks the address says and what the sender thinks it says.",[10,478,479],{},"That gap has a name. CWE-180, \"Incorrect Behavior Order: Validate Before Canonicalize.\" Canonicalize first, then validate, and all three bugs disappear at once. That is what 4.24.15 finally does.",[481,482,484],"finding-box",{"title":483},"Why this pattern shows up in AI-written code constantly",[10,485,486,487,490],{},"Ask an assistant to validate an email and you reliably get a regex or a ",[23,488,489],{},"split(\"@\")"," length check, applied to the raw input. The same ordering mistake, in the same place. We see hand-rolled versions of this exact check in scans all the time, sitting in front of password reset and invite flows that were never in an advisory because nobody looked.",[47,492,494],{"id":493},"what-being-on-an-affected-version-does-not-tell-you","What being on an affected version does not tell you",[10,496,497],{},"The exploit needs a second thing your version number cannot answer: your mail sender has to normalize the recipient address.",[10,499,500,501,504],{},"The advisory says senders that handle internationalized email commonly apply NFKC. It does not name one. That uncertainty is encoded right in the CVSS vector as ",[23,502,503],{},"AT:P",", attack requirements present.",[10,506,507,508,511,512,514,515,520],{},"We have since measured one of them. ",[430,509,510],{},"Nodemailer does not normalize",": we passed the fullwidth ",[23,513,29],{}," payload through nodemailer 9.0.0 and 9.1.0 and the character survived untouched in both, so the address never gains a second separator and the chain does not complete. If you use the Nodemailer provider, this attack is not your exposure. ",[516,517,519],"a",{"href":518},"/blog/vulnerabilities/nodemailer-address-parser-differential","The full test, and the two separate nodemailer domain-parsing bugs we found along the way",", are written up separately. Resend, SendGrid and Postmark are still unmeasured, so treat those stacks as unknown rather than safe.",[10,522,523],{},"There is also no public evidence anyone has exploited it. No proof of concept shipped with the advisory, which is a contrast with the 2025 misdelivery bug that did ship one, and it is not on CISA's Known Exploited Vulnerabilities list.",[10,525,526],{},"None of that is a reason to stay unpatched. It is a reason not to tell your users you were breached.",[10,528,529],{},"If you want to know rather than assume, send yourself one:",[52,531,533],{"label":532},"Test whether your own sender normalizes",[56,534,538],{"className":535,"code":536,"language":537,"meta":61,"style":61},"language-js shiki shiki-themes github-dark","// Send to an address containing U+FF20 at a domain you control.\n// If it arrives at the SECOND domain, your sender normalizes.\nconst probe = \"you@your-domain.test＠second-domain-you-own.test\";\n\nawait mailer.send({ to: probe, subject: \"normalization probe\", text: \"arrived\" });\n","js",[23,539,540,545,550,565,569],{"__ignoreMap":61},[65,541,542],{"class":67,"line":68},[65,543,544],{"class":248},"// Send to an address containing U+FF20 at a domain you control.\n",[65,546,547],{"class":67,"line":193},[65,548,549],{"class":248},"// If it arrives at the SECOND domain, your sender normalizes.\n",[65,551,552,554,557,559,562],{"class":67,"line":202},[65,553,159],{"class":158},[65,555,556],{"class":178}," probe",[65,558,226],{"class":158},[65,560,561],{"class":75}," \"you@your-domain.test＠second-domain-you-own.test\"",[65,563,564],{"class":168},";\n",[65,566,567],{"class":67,"line":217},[65,568,242],{"emptyLinePlaceholder":241},[65,570,571,574,577,580,583,586,589,592],{"class":67,"line":238},[65,572,573],{"class":158},"await",[65,575,576],{"class":168}," mailer.",[65,578,579],{"class":71},"send",[65,581,582],{"class":168},"({ to: probe, subject: ",[65,584,585],{"class":75},"\"normalization probe\"",[65,587,588],{"class":168},", text: ",[65,590,591],{"class":75},"\"arrived\"",[65,593,594],{"class":168}," });\n",[10,596,597],{},"Use two domains you actually own. Never point a probe at somebody else's.",[47,599,601],{"id":600},"fix-it","Fix it",[603,604,606],"step",{"number":605},"1",[10,607,608,609,612,613,616,617,620],{},"Upgrade. ",[23,610,611],{},"npm i next-auth@4.24.15"," on v4, ",[23,614,615],{},"npm i next-auth@beta"," for v5, or ",[23,618,619],{},"@auth/core@0.41.3"," if you use the core package directly.",[603,622,624],{"number":623},"2",[10,625,626,627,629,630,633,634,637,638,641],{},"If you cannot upgrade today, supply your own ",[23,628,443],{}," that canonicalizes before it validates. In v4 that goes on ",[23,631,632],{},"EmailProvider","; in v5 it goes on the ",[23,635,636],{},"Nodemailer"," or ",[23,639,640],{},"Resend"," provider.",[52,643,645],{"label":644},"The stopgap: normalize, then check",[56,646,648],{"className":149,"code":647,"language":151,"meta":61,"style":61},"normalizeIdentifier(identifier: string) {\n  // Canonicalize FIRST. This is the whole fix.\n  const email = identifier.normalize(\"NFKC\").toLowerCase().trim();\n\n  if (email.includes('\"')) throw new Error(\"Invalid email address format.\");\n  if ((email.match(/@/g) ?? []).length !== 1) {\n    throw new Error(\"Invalid email address format.\");\n  }\n  // Optional, and worth it if you do not need international addresses:\n  if (/[^\\x20-\\x7E]/.test(email)) throw new Error(\"Invalid email address format.\");\n\n  return email;\n}\n",[23,649,650,657,662,696,700,733,768,783,788,793,832,836,844],{"__ignoreMap":61},[65,651,652,654],{"class":67,"line":68},[65,653,443],{"class":71},[65,655,656],{"class":168},"(identifier: string) {\n",[65,658,659],{"class":67,"line":193},[65,660,661],{"class":248},"  // Canonicalize FIRST. This is the whole fix.\n",[65,663,664,667,670,672,674,677,679,682,685,688,691,693],{"class":67,"line":202},[65,665,666],{"class":158},"  const",[65,668,669],{"class":178}," email",[65,671,226],{"class":158},[65,673,229],{"class":168},[65,675,676],{"class":71},"normalize",[65,678,280],{"class":168},[65,680,681],{"class":75},"\"NFKC\"",[65,683,684],{"class":168},").",[65,686,687],{"class":71},"toLowerCase",[65,689,690],{"class":168},"().",[65,692,232],{"class":71},[65,694,695],{"class":168},"();\n",[65,697,698],{"class":67,"line":217},[65,699,242],{"emptyLinePlaceholder":241},[65,701,702,705,708,711,713,716,719,722,724,726,728,730],{"class":67,"line":238},[65,703,704],{"class":158},"  if",[65,706,707],{"class":168}," (email.",[65,709,710],{"class":71},"includes",[65,712,280],{"class":168},[65,714,715],{"class":75},"'\"'",[65,717,718],{"class":168},")) ",[65,720,721],{"class":158},"throw",[65,723,329],{"class":158},[65,725,332],{"class":71},[65,727,280],{"class":168},[65,729,337],{"class":75},[65,731,732],{"class":168},");\n",[65,734,735,737,740,742,744,746,748,750,752,754,756,758,761,764,766],{"class":67,"line":245},[65,736,704],{"class":158},[65,738,739],{"class":168}," ((email.",[65,741,277],{"class":71},[65,743,280],{"class":168},[65,745,283],{"class":75},[65,747,25],{"class":286},[65,749,283],{"class":75},[65,751,291],{"class":158},[65,753,182],{"class":168},[65,755,296],{"class":158},[65,757,299],{"class":168},[65,759,760],{"class":178},"length",[65,762,763],{"class":158}," !==",[65,765,317],{"class":178},[65,767,320],{"class":168},[65,769,770,773,775,777,779,781],{"class":67,"line":252},[65,771,772],{"class":158},"    throw",[65,774,329],{"class":158},[65,776,332],{"class":71},[65,778,280],{"class":168},[65,780,337],{"class":75},[65,782,732],{"class":168},[65,784,785],{"class":67,"line":258},[65,786,787],{"class":168},"  }\n",[65,789,790],{"class":67,"line":264},[65,791,792],{"class":248},"  // Optional, and worth it if you do not need international addresses:\n",[65,794,795,797,799,801,804,807,810,812,814,817,820,822,824,826,828,830],{"class":67,"line":305},[65,796,704],{"class":158},[65,798,169],{"class":168},[65,800,283],{"class":75},[65,802,803],{"class":178},"[",[65,805,806],{"class":158},"^",[65,808,809],{"class":178},"\\x20-\\x7E]",[65,811,283],{"class":75},[65,813,372],{"class":168},[65,815,816],{"class":71},"test",[65,818,819],{"class":168},"(email)) ",[65,821,721],{"class":158},[65,823,329],{"class":158},[65,825,332],{"class":71},[65,827,280],{"class":168},[65,829,337],{"class":75},[65,831,732],{"class":168},[65,833,834],{"class":67,"line":323},[65,835,242],{"emptyLinePlaceholder":241},[65,837,838,841],{"class":67,"line":343},[65,839,840],{"class":158},"  return",[65,842,843],{"class":168}," email;\n",[65,845,846],{"class":67,"line":349},[65,847,848],{"class":168},"}\n",[603,850,852],{"number":851},"3",[10,853,854,855,858],{},"While you are in there, check the rest of the batch. Four advisories shipped in these same patch releases. One of them, CVE-2026-73421, lets a configuration error make ",[23,856,857],{},"if (req.auth)"," true for every request, which we wrote up separately. It affects v5 only and is arguably the worse of the two.",[603,860,862],{"number":861},"4",[10,863,864,865,867,868,871],{},"Grep your own code for the same ordering. ",[23,866,489],{},", ",[23,869,870],{},".length !== 1",", or an email regex applied to raw input, anywhere near a password reset, an invite, or a team join flow, is the same bug without a CVE number.",[873,874,875,898,919,928,934],"faq-section",{},[876,877,879],"faq-item",{"question":878},"Am I affected by CVE-2026-73420?",[10,880,881,882,885,886,888,889,891,892,894,895,897],{},"Run ",[23,883,884],{},"npm ls next-auth @auth/core",". You are in the affected range on ",[23,887,36],{}," 4.10.3 up to but not including 4.24.15, on any ",[23,890,36],{}," 5.0.0-beta.1 through beta.31, or on ",[23,893,44],{}," below 0.41.3. That ",[23,896,44],{}," floor of 0.1.0 means effectively every version ever published. You also need to be using the email or magic-link provider at all. OAuth-only apps are not in scope for this one.",[876,899,901],{"question":900},"What actually goes wrong?",[10,902,903,904,906,907,909,910,912,913,915,916,918],{},"The default email normalizer counts ",[23,905,25],{}," symbols and rejects anything that does not have exactly one. A fullwidth commercial at, ",[23,908,29],{},", is not an ASCII ",[23,911,25],{},", so an address containing it passes that count with one real ",[23,914,25],{},". When a downstream mail library applies Unicode NFKC normalization, the fullwidth character folds into a plain ",[23,917,25],{},", the address now has two separators, and the sign-in link can be delivered to the second domain.",[876,920,922],{"question":921},"Is my app actually exploitable, or just on an affected version?",[10,923,924,925,927],{},"Being on an affected version is not the same as being exploitable. The attack also needs your downstream mail sender to apply Unicode normalization to recipient addresses. We tested nodemailer and it does not: the fullwidth ",[23,926,29],{}," survives untouched in 9.0.0 and 9.1.0, so the chain does not complete through the Nodemailer provider. Resend, SendGrid and Postmark remain untested, so treat those as unmeasured until you check them.",[876,929,931],{"question":930},"Was this exploited in the wild?",[10,932,933],{},"There is no public evidence of exploitation. No proof of concept shipped with the advisory, unlike the 2025 misdelivery bug in the same code path, and the CVE is not on CISA's Known Exploited Vulnerabilities list. The 9.1 rating describes what the bug allows, not something that is known to have happened.",[876,935,937],{"question":936},"Why does patching keep introducing the next version of this bug?",[10,938,939],{},"All three fixes are string checks bolted in front of a mail library that parses addresses by a different set of rules. The 2022 fix stripped commas. The 2025 fix counted at-signs and rejected quotes. Each closed the exact input that had been reported and left the general problem in place, which is that your validator and your mail sender disagree about what the address says.",[941,942,943,949,954],"related-articles",{},[944,945],"related-card",{"description":946,"href":947,"title":948},"CVE-2026-73421, from the same July patch releases: a config error makes the auth check pass for everyone","/blog/vulnerabilities/next-auth-v5-fail-open","next-auth v5 Fail-Open",[944,950],{"description":951,"href":952,"title":953},"Single-use tokens, expiry, and the out-of-band hop this bug exploits","/blog/how-to/magic-links","How to Implement Magic Links Securely",[944,955],{"description":956,"href":957,"title":958},"The rival library's version of a magic link landing on the wrong account","/blog/vulnerabilities/better-auth-pre-account-hijacking","Better Auth Account Takeover",[960,961,963,967],"cta-box",{"href":283,"label":962},"Start Free Scan",[47,964,966],{"id":965},"check-what-your-login-flow-actually-exposes","Check what your login flow actually exposes",[10,968,969],{},"A scan reads your deployed sign-in surface for missing rate limits, leaky error messages, and endpoints that accept more than they should.",[971,972,973],"style",{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sns5M, html code.shiki .sns5M{--shiki-default:#DBEDFF}",{"title":61,"searchDepth":193,"depth":193,"links":975},[976,977,978,979,980,981],{"id":49,"depth":193,"text":50},{"id":133,"depth":193,"text":134},{"id":415,"depth":193,"text":416},{"id":493,"depth":193,"text":494},{"id":600,"depth":193,"text":601},{"id":965,"depth":193,"text":966},"vulnerabilities","2026-09-02","2026-09-09","A sign-in link can be delivered to an attacker because Auth.js counts the @ symbols before Unicode normalization turns one into two. Here is the version check to run.",false,"md",[989,991,993,995,996],{"question":878,"answer":990},"Run npm ls next-auth @auth/core. You are in the affected range on next-auth 4.10.3 up to but not including 4.24.15, on any next-auth 5.0.0-beta.1 through beta.31, or on @auth/core below 0.41.3. That @auth/core floor of 0.1.0 means effectively every version ever published. You also need to be using the email or magic-link provider at all. OAuth-only apps are not in scope for this one.",{"question":900,"answer":992},"The default email normalizer counts @ symbols and rejects anything that does not have exactly one. A fullwidth commercial at, U+FF20, is not an ASCII @, so an address containing it passes that count with one real @. When a downstream mail library applies Unicode NFKC normalization, the fullwidth character folds into a plain @, the address now has two separators, and the sign-in link can be delivered to the second domain.",{"question":921,"answer":994},"Being on an affected version is not the same as being exploitable. The attack also needs your downstream mail sender to apply Unicode normalization to recipient addresses. We tested nodemailer and it does not: the fullwidth `U+FF20` survives untouched in 9.0.0 and 9.1.0, so the chain does not complete through the Nodemailer provider. Resend, SendGrid and Postmark remain untested, so treat those as unmeasured until you check them.",{"question":930,"answer":933},{"question":936,"answer":939},"red",null,"next-auth magic link vulnerability, CVE-2026-73420, auth.js homoglyph, next-auth email login security, next-auth 4.24.15, auth core 0.41.3, normalizeIdentifier, magic link sent to wrong email",{"trendTrigger":1001,"trendDate":1002},"authjs-advisory-GHSA-7rqj-j65f-68wh-homoglyph-at-normalization-order","2026-07-23","The third magic-link misdelivery bug in next-auth in four years, and each one lives inside the fix for the last.","/blog/vulnerabilities/nextauth-magic-link-homoglyph","9 min read","[object Object]","TechArticle",{"title":5,"description":985},{"loc":1004},"blog/vulnerabilities/nextauth-magic-link-homoglyph",[],"summary_large_image","PWdLQeZsywYfprQ4oh1-xSzlWlm-yLTNv83EyY7pa98",1789672868598]