<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ARLing</title>
    <description>The latest articles on DEV Community by ARLing (@arling).</description>
    <link>https://dev.to/arling</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4109895%2Fde4962bc-3ed0-44cf-8b9f-daab492731c2.jpeg</url>
      <title>DEV Community: ARLing</title>
      <link>https://dev.to/arling</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arling"/>
    <language>en</language>
    <item>
      <title>I shipped 14 free tools in 3 days with AI. What worked, what did not, and the numbers.</title>
      <dc:creator>ARLing</dc:creator>
      <pubDate>Fri, 04 Sep 2026 15:17:59 +0000</pubDate>
      <link>https://dev.to/arling/i-shipped-14-free-tools-in-3-days-with-ai-what-worked-what-did-not-and-the-numbers-4cho</link>
      <guid>https://dev.to/arling/i-shipped-14-free-tools-in-3-days-with-ai-what-worked-what-did-not-and-the-numbers-4cho</guid>
      <description>&lt;p&gt;I run ARLing s. r. o. out of Bratislava. It's a one-person company: I'm 22, I can't write code, and most of what gets built is written by AI. Three days ago I decided to test how far that setup could go if I pushed it hard: pick real problems, ship free tools that fix them, and see what happens.&lt;/p&gt;

&lt;p&gt;This is not a growth story. After 48 hours the tools have a few dozen visitors each, no email signups, and one paid sale that happened today, the first day a paid product existed. I'm writing it anyway because the build side worked better than I expected, and the numbers are worth being honest about.&lt;/p&gt;

&lt;p&gt;Written with AI assistance and published by me. Numbers are from my own self-hosted analytics.&lt;/p&gt;

&lt;h2&gt;
  
  
  What got built
&lt;/h2&gt;

&lt;p&gt;Over three days, 14 pages went live on arling.sk:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;8 "doctor" tools, each diagnosing one specific, common error: an Expo plus Supabase redirect problem, a Supabase web redirect issue, a Flutter deep link issue, Universal Links setup, Google OAuth redirect errors, SEPA payment file errors, a Stripe webhook checker, and one more.&lt;/li&gt;
&lt;li&gt;SEPA Generator Pro, a paid tool built with Stripe, for accountants who need to produce SEPA payment files.&lt;/li&gt;
&lt;li&gt;A beta of a book club app (bookapp).&lt;/li&gt;
&lt;li&gt;Docs, notes, a privacy page, and a "how we work" page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first tool, a redirect checker for Expo and Supabase apps, went from idea to live page in about 4 hours on day one. Two more tools followed the same day. On day two, three more tools came out of a single 30 minute build workflow, along with the bookapp beta. Day three added two more tools plus the Stripe based generator.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the work actually splits
&lt;/h2&gt;

&lt;p&gt;I don't write code. What I do is decide which problems are worth a tool, answer the people who show up, and say no to ideas that don't fit. Claude agents write the code, the tests, the pages, and the technical hygiene: robots.txt, sitemap, structured data.&lt;/p&gt;

&lt;p&gt;The setup underneath: one orchestrator model plans a tool, then cheaper models build and verify the pieces in parallel. A typical tool uses three agents, one for the engine and UI, one for hygiene and launch notes, one for tests. Each tool runs entirely in the browser, nothing gets uploaded anywhere, and every rule a tool checks against is cited to the official documentation it comes from. Test counts per tool, across the eight doctor tools: 52, 87, 94, 106, 125, 108, 125, 178 automated tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design mistake
&lt;/h2&gt;

&lt;p&gt;The first version of every page looked the same: dark background, orange gradient buttons, everything in cards. It's a recognizable look at this point, generic AI output. I looked at it and called it AI slop. So every page got rebuilt on day two: a light, typographic layout, real screenshots instead of icons and cards, plain sentences instead of the usual AI writing tics. It meant redoing pages that already worked, but the result reads like something a person made on purpose, not a template.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers, 48 hours in
&lt;/h2&gt;

&lt;p&gt;Unique visitors, from self-hosted Umami analytics, no cookies:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Unique visitors&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Expo redirect doctor&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hub (arling.sk)&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bookapp beta&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web Supabase redirect tool&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flutter deep link tool&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Universal Links checker&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google OAuth checker&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe webhook checker&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SEPA Generator Pro&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SEPA Doctor&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Roughly one in ten visitors actually runs a diagnosis, instead of just landing on the page. About half of visitors are from the US, the rest mostly Slovakia, the Netherlands, Germany, and Canada. Email signups: 0. Paid sales: 0, though the first paid product only went live today. One person downloaded the bookapp APK.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did not work
&lt;/h2&gt;

&lt;p&gt;Early on, each tool had a "would you pay for Pro" button, meant to gauge interest before I built anything paid. It measured nothing: zero clicks, across every tool. I pulled it and put an email signup in its place instead, on the theory that asking for an email is a real commitment and a button click is not.&lt;/p&gt;

&lt;p&gt;Distribution took more effort than building did: technical replies in 12 GitHub threads, posted from my own account with consent, 3 pull requests to awesome list repos, submissions to Bing and IndexNow, a Google Search Console setup, and 6 dev.to notes. No Hacker News or Reddit post yet. Both communities have explicit rules against AI written text, and posting there has to be me, in my own words. I'll do that myself, separately.&lt;/p&gt;

&lt;p&gt;The clearest lesson from three days: building is no longer the bottleneck. AI agents can put a tested, documented tool online faster than I can find people to look at it. Distribution is the actual constraint now.&lt;/p&gt;

&lt;p&gt;The other pattern in the numbers: tools that fix a one time error get visitors, and then those visitors leave, because once their redirect or OAuth setup works, they have no reason to come back. That is fine for traffic, but it is not a business by itself. The two things I am actually betting on are recurring problems: SEPA payment files for accountants, who run into this every month, and a culling app for photographers, who shoot constantly. Free error fixing tools bring people to the site; the paid products need a reason for people to come back.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;I post to Hacker News and Reddit myself, in my own words, once there is enough here worth talking about. Costs so far: 0 EUR beyond the AI subscription I already pay for, and a 12 EUR domain. My own target is 200 EUR in revenue by September 27 to justify keeping this pace up. Right now that number is 0, so the next two weeks are about turning a few dozen daily visitors per tool into people who actually pay for the two products built to be paid for.&lt;/p&gt;

&lt;p&gt;If you want to see what is live: the hub is at &lt;a href="https://arling.sk/" rel="noopener noreferrer"&gt;https://arling.sk/&lt;/a&gt;, there is a page on how the human and AI split actually works at &lt;a href="https://arling.sk/how-we-work/" rel="noopener noreferrer"&gt;https://arling.sk/how-we-work/&lt;/a&gt;, and the running build notes are at &lt;a href="https://arling.sk/notes/" rel="noopener noreferrer"&gt;https://arling.sk/notes/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Written with AI assistance and published by me. Numbers are from my own self-hosted analytics.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>startup</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Supabase OAuth redirects to localhost in production: the allow-list rule nobody reads</title>
      <dc:creator>ARLing</dc:creator>
      <pubDate>Fri, 04 Sep 2026 15:06:09 +0000</pubDate>
      <link>https://dev.to/arling/supabase-oauth-redirects-to-localhost-in-production-the-allow-list-rule-nobody-reads-3g1b</link>
      <guid>https://dev.to/arling/supabase-oauth-redirects-to-localhost-in-production-the-allow-list-rule-nobody-reads-3g1b</guid>
      <description>&lt;p&gt;Notes&lt;/p&gt;

&lt;p&gt;Supabase OAuth redirects to localhost in production: the allow-list rule nobody reads&lt;br&gt;
  Andrej Lauko, ARLing · 6 September 2026&lt;/p&gt;

&lt;p&gt;You see this after a deploy, not before: a user clicks "Continue with Google" on your live domain, gets through the Google consent screen, and the browser lands them back on &lt;code&gt;http://localhost:3000/?code=4/0AeaYSHD...&lt;/code&gt; instead of your production site. The login itself worked. Supabase just sent the browser somewhere else afterward, and it did that on purpose: whenever the redirect it would use isn't covered by the Redirect URLs allow-list, Supabase falls back to the Site URL in your project's Authentication settings, silently, with no error page and no console warning. If that Site URL is still set to localhost, so is every failed redirect, including ones in production.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Site URL is still Supabase's own default
&lt;/h2&gt;

&lt;p&gt;A new Supabase project ships with Site URL set to &lt;code&gt;http://localhost:3000&lt;/code&gt;. That value isn't just a placeholder: per Supabase's own docs, "the Site URL in URL Configuration defines the default redirect URL when no redirectTo is specified in the code," and it's also the fallback for any redirectTo that gets rejected. Plenty of projects go to production without anyone ever opening this setting, so the fallback stays localhost forever.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Site URL (still the default):  http://localhost:3000
Site URL (fix):                https://yourapp.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix: in the Supabase dashboard, go to Authentication → URL Configuration → Site URL and set it to your exact production origin, https, no trailing slash.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The callback URL isn't on the Redirect URLs allow-list
&lt;/h2&gt;

&lt;p&gt;Even with a correct Site URL, an explicit &lt;code&gt;redirectTo&lt;/code&gt; that isn't on the allow-list gets rejected the same way. The allow-list uses glob patterns, not plain strings: Supabase's docs define &lt;code&gt;*&lt;/code&gt; as matching "any sequence of non-separator characters," &lt;code&gt;**&lt;/code&gt; as "any sequence of characters," and state that "the separator characters in a URL are defined as . and /." A pattern that looks close enough on a quick read often isn't a match at all, and a trailing-slash difference between your callback and the allow-list entry is enough to fail.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Redirect URLs
https://yourapp.com/auth/callback
https://yourapp.com/**
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix: add the exact callback URL your app builds, then a broader &lt;code&gt;https://yourapp.com/**&lt;/code&gt; pattern to cover any path under it. If you deploy previews on Vercel or Netlify, add their documented wildcard too: &lt;code&gt;https://*-&amp;lt;team-slug&amp;gt;.vercel.app/**&lt;/code&gt; or &lt;code&gt;https://**--&amp;lt;site-name&amp;gt;.netlify.app/**&lt;/code&gt;, or preview builds will fail the same way production did.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. redirectTo is hardcoded to localhost
&lt;/h2&gt;

&lt;p&gt;The most direct cause: a literal localhost string sitting in the &lt;code&gt;signInWithOAuth&lt;/code&gt; call, written during local development and never swapped out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wrong: ships to production verbatim&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signInWithOAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;google&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;redirectTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3000/auth/callback&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// fix: derive it from where the app is actually running&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signInWithOAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;google&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;redirectTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/auth/callback`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a server-rendered route, guard the origin instead of hardcoding either value: &lt;code&gt;typeof window !== 'undefined' ? window.location.origin : process.env.NEXT_PUBLIC_SITE_URL&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. process?.env quietly resolves to undefined
&lt;/h2&gt;

&lt;p&gt;A subtler version of the same bug, reported in a Supabase GitHub discussion: a Next.js app read its production URL from &lt;code&gt;process?.env.NEXT_PUBLIC_SITE_URL&lt;/code&gt;, worked in local dev, and redirected to localhost once deployed. The cause is how Next.js inlines env vars: as one commenter put it, "Next.js inlines process.env.NEXT_PUBLIC_* via static analysis at build time. It looks for that literal member-expression pattern in your source and swaps in the real string. process?.env?.NEXT_PUBLIC_SITE_URL (or any dynamic/bracket access) doesn't match that pattern, so the bundler can't inline it." The optional chaining on &lt;code&gt;process&lt;/code&gt; itself, not on a property, is what breaks the build-time substitution: the browser bundle keeps the literal expression, &lt;code&gt;process&lt;/code&gt; is undefined client-side, and the whole thing silently becomes &lt;code&gt;undefined&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wrong: optional chaining on process defeats build-time inlining&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_SITE_URL&lt;/span&gt;

&lt;span class="c1"&gt;// fix: direct property access&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_SITE_URL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix: search your codebase for &lt;code&gt;process?.env&lt;/code&gt; and replace it with plain &lt;code&gt;process.env.VAR&lt;/code&gt;, or &lt;code&gt;import.meta.env.VAR&lt;/code&gt; on Vite.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Site URL doesn't match your production domain
&lt;/h2&gt;

&lt;p&gt;A Site URL that's set, uses https, and still isn't localhost can still be wrong: a www vs. apex mismatch, or a different domain than the one users actually land on. Supabase treats Site URL as an exact match against your app's real origin, so &lt;code&gt;https://example.com&lt;/code&gt; and &lt;code&gt;https://www.example.com&lt;/code&gt; are two different values to it, not the same site.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Site URL:            https://example.com
Where users land:    https://www.example.com  ← mismatch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix: pick one as canonical, set Site URL to exactly that, and if you still serve the other domain, add it to the allow-list too or redirect it to the canonical one before it ever reaches Supabase.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Checklist
&lt;/h2&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Set Supabase Site URL to your exact production origin: https, no trailing slash, no www/apex mismatch.

- Add your app's exact callback URL to Authentication → URL Configuration → Redirect URLs.

- Search the codebase for a hardcoded localhost in any redirectTo or emailRedirectTo call.

- Search for process?.env and switch it to direct property access.

- If you deploy previews on Vercel or Netlify, allow-list their wildcard pattern too.

- Re-check after every deploy: Supabase settings and your OAuth provider console drift independently from your code.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  7. Check it in 30 seconds
&lt;/h2&gt;

&lt;p&gt;Paste your production origin, Supabase Site URL and Redirect URLs into the &lt;a href="https://arling.sk/supabase-redirect-doctor/" rel="noopener noreferrer"&gt;Supabase Auth Redirect Doctor&lt;/a&gt; and it lists every mismatch by name, with the exact value to fix it.&lt;/p&gt;
&lt;h2&gt;
  
  
  8. Sources
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Supabase docs: Redirect URLs. Site URL as default redirect, and the glob syntax for the allow-list (*, **, and .// as separators).

- Supabase docs: Login with Google. The https://&amp;lt;project-ref&amp;gt;.supabase.co/auth/v1/callback format and where to add it in Google Cloud Console's Authorized redirect URIs.

- Supabase docs: Server-side rendering with Next.js. @supabase/ssr, createServerClient/createBrowserClient, and cookie-based sessions.

- Supabase GitHub discussion #38063. The reported process?.env case and why optional chaining on process defeats Next.js's build-time env inlining.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;




&lt;p&gt;Originally published at &lt;a href="https://arling.sk/notes/supabase-oauth-redirects-to-localhost/" rel="noopener noreferrer"&gt;arling.sk&lt;/a&gt;. Written by Andrej Lauko with AI assistance; every rule links to the official documentation it comes from.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>nextjs</category>
      <category>oauth</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Supabase Flutter deep link not working: scheme, Info.plist, AndroidManifest and the underscore bug</title>
      <dc:creator>ARLing</dc:creator>
      <pubDate>Fri, 04 Sep 2026 15:06:07 +0000</pubDate>
      <link>https://dev.to/arling/supabase-flutter-deep-link-not-working-scheme-infoplist-androidmanifest-and-the-underscore-bug-1af7</link>
      <guid>https://dev.to/arling/supabase-flutter-deep-link-not-working-scheme-infoplist-androidmanifest-and-the-underscore-bug-1af7</guid>
      <description>&lt;p&gt;&lt;a href="https://arling.sk/" rel="noopener noreferrer"&gt;ARLing&lt;/a&gt; / &lt;a href="https://arling.sk/notes/" rel="noopener noreferrer"&gt;Notes&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Supabase Flutter deep link not working: scheme, Info.plist, AndroidManifest and the underscore bug&lt;br&gt;
  You call &lt;code&gt;signInWithOAuth()&lt;/code&gt;, sign in with Google, and the browser never hands control back to the app. Instead it sits on &lt;code&gt;http://localhost:3000/&lt;/code&gt;, or Android logs &lt;code&gt;ActivityNotFoundException: No Activity found to handle Intent { act=android.intent.action.VIEW dat=your_app://login-callback }&lt;/code&gt;, while the Flutter app is still showing the sign-in screen. &lt;code&gt;supabase_flutter&lt;/code&gt; needs five places to agree on the exact same scheme and host: your Dart code, &lt;code&gt;Info.plist&lt;/code&gt;, &lt;code&gt;AndroidManifest.xml&lt;/code&gt;, the Supabase redirect allow-list, and the OAuth provider console. Here is each place it breaks, one at a time.&lt;/p&gt;

&lt;p&gt;01&lt;/p&gt;
&lt;h2&gt;
  
  
  The scheme or host has an underscore
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;app.scheme&lt;/code&gt; or &lt;code&gt;app.host&lt;/code&gt; contains an underscore, for example &lt;code&gt;my_app://callback&lt;/code&gt;, the redirect fails even when Info.plist, AndroidManifest and the Supabase allow-list all list it correctly. Google's OAuth redirect handling mangles underscores in redirect URLs: a scheme or host like &lt;code&gt;my_app&lt;/code&gt; always fails Supabase's redirect-URL check and silently falls back to the Site URL. This is tracked upstream as &lt;a href="https://github.com/supabase/auth/issues/2447" rel="noopener noreferrer"&gt;supabase/auth#2447&lt;/a&gt; and there is no server-side fix. The underscore itself is the bug, so rename it to a hyphen everywhere it appears.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;// before&lt;/span&gt;
&lt;span class="na"&gt;scheme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my_app"&lt;/span&gt;
&lt;span class="na"&gt;redirectTo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my_app://login-callback"&lt;/span&gt;

&lt;span class="s"&gt;// after&lt;/span&gt;
&lt;span class="na"&gt;scheme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-app"&lt;/span&gt;
&lt;span class="na"&gt;redirectTo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-app://login-callback"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;02&lt;/p&gt;

&lt;h2&gt;
  
  
  The scheme itself is not valid
&lt;/h2&gt;

&lt;p&gt;A custom URL scheme must start with a letter and use only letters, digits, &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;-&lt;/code&gt; and &lt;code&gt;.&lt;/code&gt;. No spaces, and no case mismatch: schemes are matched case-sensitively at OS registration level, so &lt;code&gt;io.supabase.MyApp&lt;/code&gt; and &lt;code&gt;io.supabase.myapp&lt;/code&gt; are two different schemes to Android and iOS. And &lt;code&gt;http&lt;/code&gt; or &lt;code&gt;https&lt;/code&gt; are not custom schemes at all: those only work through iOS Universal Links or Android App Links, which need &lt;code&gt;apple-app-site-association&lt;/code&gt;, &lt;code&gt;assetlinks.json&lt;/code&gt; and &lt;code&gt;android:autoVerify&lt;/code&gt; on a real https host, a separate setup from what is described here. A safe default is a reverse-domain identifier such as &lt;code&gt;io.supabase.myapp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;03&lt;/p&gt;

&lt;h2&gt;
  
  
  Info.plist does not list the scheme
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CFBundleURLSchemes&lt;/code&gt; in &lt;code&gt;ios/Runner/Info.plist&lt;/code&gt; has to contain the exact scheme. Without it, iOS has nothing registered to hand the redirect to: Safari, or the in-app browser tab the OAuth screen opened, just sits on the URL, and the OS may report that it cannot open the link at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;CFBundleURLTypes


    CFBundleURLSchemes

      io.supabase.myapp



&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;04&lt;/p&gt;

&lt;h2&gt;
  
  
  AndroidManifest's intent-filter is missing or does not match
&lt;/h2&gt;

&lt;p&gt;Android needs an &lt;code&gt;&amp;lt;intent-filter&amp;gt;&lt;/code&gt; on the launcher activity with &lt;code&gt;android:scheme&lt;/code&gt;, and &lt;code&gt;android:host&lt;/code&gt; if your redirect uses one, equal to &lt;code&gt;app.scheme&lt;/code&gt; / &lt;code&gt;app.host&lt;/code&gt;. Missing it entirely produces &lt;code&gt;ActivityNotFoundException&lt;/code&gt; in logcat. A mismatched scheme or host, often left over from an old value, fails the same way but silently: nothing in the manifest is wrong on its own, it just never fires for this redirect. One more detail: &lt;code&gt;android:autoVerify="true"&lt;/code&gt; does nothing on a custom-scheme filter. It only triggers Digital Asset Links verification for https App Links, so leave it off here.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;





&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;05&lt;/p&gt;

&lt;h2&gt;
  
  
  redirectTo is not on the Supabase allow-list, or does not match exactly
&lt;/h2&gt;

&lt;p&gt;Whatever string &lt;code&gt;signInWithOAuth(redirectTo: ...)&lt;/code&gt; sends has to equal &lt;code&gt;scheme://host&lt;/code&gt; byte for byte, trailing slash included, and it has to be covered by an entry under Supabase → Authentication → URL Configuration → Redirect URLs, either the exact string or a glob such as &lt;code&gt;io.supabase.myapp://**&lt;/code&gt;. If it is not covered, Supabase does not throw an error: it silently falls back to the Site URL, so the browser ends up in your web app, or on &lt;code&gt;localhost&lt;/code&gt; if that is still what Site URL points to, instead of back in the mobile app. The failure looks identical to every other cause on this page, which is why it is worth checking the allow-list first.&lt;/p&gt;

&lt;p&gt;06&lt;/p&gt;

&lt;h2&gt;
  
  
  The provider console has the wrong redirect URI
&lt;/h2&gt;

&lt;p&gt;Google, Apple, GitHub, Discord and Kakao all need the Supabase project's own callback in their console, not your app's scheme: &lt;code&gt;https://&amp;lt;project-ref&amp;gt;.supabase.co/auth/v1/callback&lt;/code&gt;, an exact match, no wildcards accepted. The handshake goes provider to Supabase to app: the provider redirects to Supabase first, and only Supabase then redirects on to your app's custom scheme. Putting &lt;code&gt;io.supabase.myapp://login-callback&lt;/code&gt; in the provider's own settings breaks the first hop, before your app's scheme is even involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Scheme and host use only lowercase letters, digits, - and .: no underscores, no spaces.

- Scheme is a custom scheme, not http or https, unless Universal Links / App Links are set up separately.

- Info.plist's CFBundleURLSchemes contains the exact scheme.

- AndroidManifest's intent-filter scheme, and host if used, matches exactly, with no android:autoVerify on it.

- redirectTo in code equals scheme://host exactly, including the trailing slash.

- That value, or a matching pattern, is on Supabase's Redirect URLs list.

- Supabase's Site URL is a real production URL, not localhost.

- The OAuth provider console has the exact https://&amp;lt;ref&amp;gt;.supabase.co/auth/v1/callback.

- authFlowType is pkce and authScreenLaunchMode is LaunchMode.externalApplication.

- onAuthStateChange has a listener, so the UI updates once the session comes back.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  Check it in 30 seconds
&lt;/h2&gt;

&lt;p&gt;Paste your scheme, host, Info.plist, AndroidManifest and Supabase settings into the &lt;a href="https://arling.sk/flutter-supabase-doctor/" rel="noopener noreferrer"&gt;Supabase Deep Link Doctor for Flutter&lt;/a&gt; and it lists every mismatch by name, with the exact value to fix.&lt;/p&gt;
&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Supabase Docs, Native mobile deep linking: redirect URL format, CFBundleURLSchemes, AndroidManifest intent-filter.

- Supabase Docs, signInWithOAuth (Dart reference): redirectTo, authScreenLaunchMode, LaunchMode.externalApplication.

- pub.dev, supabase_flutter package page: deep links for magic link, e-mail confirmation, password reset and OAuth.

- GitHub, supabase/auth issue #2447: underscores in a redirect URL always fail the check and fall back to Site URL.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Andrej Lauko, ARLing. 6 September 2026.&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://arling.sk/notes/supabase-flutter-deep-link-scheme/" rel="noopener noreferrer"&gt;arling.sk&lt;/a&gt;. Written by Andrej Lauko with AI assistance; every rule links to the official documentation it comes from.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>supabase</category>
      <category>dart</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Stripe "No signatures found matching the expected signature": twelve causes, one at a time</title>
      <dc:creator>ARLing</dc:creator>
      <pubDate>Fri, 04 Sep 2026 15:00:36 +0000</pubDate>
      <link>https://dev.to/arling/stripe-no-signatures-found-matching-the-expected-signature-twelve-causes-one-at-a-time-40bb</link>
      <guid>https://dev.to/arling/stripe-no-signatures-found-matching-the-expected-signature-twelve-causes-one-at-a-time-40bb</guid>
      <description>&lt;p&gt;Notes&lt;/p&gt;

&lt;p&gt;Stripe "No signatures found matching the expected signature": twelve causes, one at a time&lt;br&gt;
  Andrej Lauko, ARLing · 6 September 2026&lt;/p&gt;

&lt;p&gt;Stripe webhook signature verification fails with this exact message when &lt;code&gt;constructEvent()&lt;/code&gt; rejects a webhook request outright, before your code ever reads the event:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Webhook signature verification failed. Err: No signatures found matching the expected signature for payload.&lt;/code&gt;&lt;br&gt;
  &lt;code&gt;constructEvent()&lt;/code&gt; checks exactly three inputs: the raw request body, the &lt;code&gt;whsec_&lt;/code&gt; signing secret, and the &lt;code&gt;Stripe-Signature&lt;/code&gt; header. It fails the same way on Next.js and on plain Express, usually because some layer before your handler already parsed or rewrote the raw body. Below are the twelve causes, one at a time.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  The body already arrived as parsed JSON
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Something upstream, a global body parser, a logging proxy, an API gateway, parsed the request body into a JavaScript object before `constructEvent()` ran. Stripe's docs are direct: "Stripe requires the raw body of the request to perform signature verification... Any manipulation to the raw body of the request causes the verification to fail." A parsed object is not the raw body, even when its content looks identical.

Fix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;02
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  The body was parsed, then re-serialized
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Some handlers call `JSON.parse()` on the body, do something with it, then call `JSON.stringify()` on the result before passing it to `constructEvent()`. Re-serializing can change whitespace or key order versus the exact bytes Stripe signed. The HMAC comparison fails even though the data looks identical.

Fix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wrong: constructEvent(JSON.stringify(JSON.parse(raw)), sig, secret)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// keep the original bytes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;03
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  A global body parser runs before the webhook route
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;In Express, `app.use(express.json())` mounted ahead of the webhook route consumes and parses the stream first. Stripe's troubleshooting docs single this out: "the order of middleware configuration matters." Scope raw-body parsing to the webhook route alone, mounted before any global parser.

Fix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="nx"&gt;webhookHandler&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// every other route, mounted after&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;04
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  Next.js App Router: req.json() instead of req.text()
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A route handler under `app/api/.../route.ts` that calls `await req.json()` has already parsed the body. Stripe's own Next.js example in the stripe-node repository reads it with `req.text()` specifically, so `constructEvent()` gets the untouched string.

Fix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// not req.json()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;05
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  Missing await on req.text()
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;`req.text()` returns a Promise. Without `await`, `constructEvent()` receives the pending Promise, not a resolved string. That produces a different error, "Webhook payload must be provided as a string or a Buffer", so if that is what you see, check this line.

Fix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;06
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  Next.js Pages Router: default body parser not disabled
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pages Router API routes parse the body as JSON by default. Without `export const config = { api: { bodyParser: false } }`, Next has already consumed and parsed the raw bytes before your handler runs.

Fix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;bodyParser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;07
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  The secret is an API key, not the signing secret
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The value stored as `STRIPE_WEBHOOK_SECRET` sometimes starts with `sk_` or `pk_`, an API key copied from the wrong place. Stripe's Dashboard docs describe the real thing plainly: "a signing secret beginning with `whsec_` appears" on the endpoint's own settings page. An API key never verifies a webhook signature.

Fix

Copy the `whsec_...` value from Dashboard, Webhooks, your endpoint, Reveal secret, or from the `stripe listen` terminal output for local testing.




08
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  The secret variable never reaches the handler
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;If the environment variable holding the secret is unset, misspelled, or simply missing in this deployment environment, `constructEvent()` throws the same "No signatures found" error as a wrong secret. Stripe's library does not distinguish an empty secret from an incorrect one.

Fix

Log the first few characters of the secret at boot (never the whole value) to confirm it actually loaded in this environment, not just on your machine.




09
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  Using the CLI's forwarding secret against a live endpoint
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The `whsec_` value printed by `stripe listen` is generated for local forwarding, not for any registered endpoint. Stripe's docs: "Don't verify signatures on events forwarded by the CLI using the secret from a Dashboard-managed endpoint, or the other way around." Every endpoint and mode, test and live, has its own secret.

Fix

Use the `whsec_` secret from Dashboard, Webhooks, the specific live-mode endpoint that is actually receiving the request.




10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  Reading the wrong header
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Stripe only ever sends the signature in the `Stripe-Signature` header (Node normalizes header names to lowercase). Reading any other header name, or a header renamed by a proxy along the way, never finds a valid value.

Fix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  Tolerance set to zero, or a skewed server clock
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;`constructEvent()` compares the timestamp Stripe signed into the header against the server clock, inside a tolerance window that defaults to 300 seconds. Stripe's docs warn: "Don't use a tolerance value of 0," it disables the recency check rather than tightening it. A clock drifted past the window fails the same way, even with a correct secret and body.

Fix

Drop any custom tolerance argument to keep the 300-second default, and sync the server clock with NTP.




12
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  Serverless and edge runtimes: base64 bodies and constructEventAsync
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;On AWS Lambda behind API Gateway, the body commonly arrives base64-encoded (`event.isBase64Encoded: true`) and must be decoded first. Cloudflare Workers and other edge runtimes have no Node crypto module, only the async Web Crypto API, so the synchronous `constructEvent()` throws `CryptoProviderOnlySupportsAsyncError`, whose message says: "Use `await constructEventAsync(...)` instead of `constructEvent(...)`."

Fix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isBase64Encoded&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// on an edge runtime:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripeEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEventAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  - Pass the raw request body, the exact bytes Stripe sent, into constructEvent(), before any JSON.parse or re-serialization touches it.

  - Confirm the whsec_ secret matches the specific endpoint receiving this request. A Dashboard-created endpoint and a running stripe listen each generate a different one, and test and live modes each have their own.

  - Read the signature from the stripe-signature header exactly, not a differently named or re-cased copy.

  - Leave tolerance at the 300-second default, and keep the server clock synced with NTP.

  - Re-check after every deploy. A new body parser, proxy, or changed environment variable can silently break raw-body access again.





14
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  Check it in 30 seconds
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The [Stripe Webhook Signature Doctor](https://arling.sk/stripe-webhook-doctor/) runs these twelve checks against your framework and your pasted code, entirely in your browser.

[Open Stripe Webhook Signature Doctor](https://arling.sk/stripe-webhook-doctor/)




15
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  - Stripe docs: Receive Stripe events in your webhook endpoint, verifying signatures

  - Stripe docs: Check webhook signatures, troubleshooting

  - Stripe docs: Stripe CLI overview

  - Next.js docs: Route Handlers (route.ts)

  - Express docs: body-parser middleware

  - stripe-node source: Webhooks.ts (default tolerance, constructEventAsync)

  - stripe-node README: raw body requirement
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;




&lt;p&gt;Originally published at &lt;a href="https://arling.sk/notes/stripe-webhook-signature-verification-failed/" rel="noopener noreferrer"&gt;arling.sk&lt;/a&gt;. Written by Andrej Lauko with AI assistance; every rule links to the official documentation it comes from.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>webhooks</category>
      <category>nextjs</category>
      <category>node</category>
    </item>
    <item>
      <title>Error 400: redirect_uri_mismatch, explained character by character</title>
      <dc:creator>ARLing</dc:creator>
      <pubDate>Fri, 04 Sep 2026 15:00:35 +0000</pubDate>
      <link>https://dev.to/arling/error-400-redirecturimismatch-explained-character-by-character-6i</link>
      <guid>https://dev.to/arling/error-400-redirecturimismatch-explained-character-by-character-6i</guid>
      <description>&lt;p&gt;Notes&lt;/p&gt;

&lt;p&gt;Error 400: redirect_uri_mismatch, explained character by character&lt;br&gt;
  Andrej Lauko, ARLing · 2026-09-06&lt;/p&gt;

&lt;p&gt;You click "Sign in with Google" and land on a Google page that says &lt;code&gt;Error 400: redirect_uri_mismatch&lt;/code&gt;, sometimes with a "Request details" box showing the redirect_uri your app sent. Nothing else runs: Google refuses the request before your callback route ever sees it. Whatever the cause, the fix has the same shape: the redirect_uri your code sends has to match, byte for byte, an entry in Authorized redirect URIs on your OAuth 2.0 Client. This is a Google OAuth setting, not a NextAuth, Supabase, or Firebase bug specifically, but each of those stacks computes the redirect_uri differently, so the exact fix depends on which one you use. Below are the causes in the order they tend to show up, and how to fix each one.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  The redirect_uri isn't in Authorized redirect URIs at all
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Google checks the incoming redirect_uri against an exact list: Authorized redirect URIs on your OAuth 2.0 Client, under Google Cloud Console → APIs &amp;amp; Services → Credentials → your OAuth 2.0 Client ID. If the value your app sent isn't in that list character for character, this is the error you get, before your callback route runs at all.

Fix: copy the redirect_uri from the error page, or from the address bar, or from your browser's Network tab on the request to `accounts.google.com/o/oauth2/v2/auth`, and add it to Authorized redirect URIs on the client your app actually uses. If you have more than one Google Cloud project, confirm you are editing the right one.




02
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  http instead of https
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Google requires the https scheme for every redirect_uri except localhost. Localhost URIs, including 127.0.0.1 and [::1], are exempt from this rule; every other host must use https.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before: http://example.com/api/auth/callback/google
After:  https://example.com/api/auth/callback/google
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;If your redirect_uri uses http on a real domain, that alone is enough to trigger the error, independent of anything else being correct.




03
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  www vs apex, or localhost vs 127.0.0.1
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Google treats `https://example.com` and `https://www.example.com` as two unrelated hosts, not variants of one site. It treats `localhost` and `127.0.0.1` the same way: same loopback purpose, different strings, different match.

Fix: use exactly one form in your code and register that same form in Authorized redirect URIs. If your app redirects www to the apex domain (or the reverse) before the OAuth request fires, the redirect_uri has to reflect the host you land on after that redirect, not before it.




04
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  A trailing slash or letter case in the path
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Google's own validation rule is explicit: the scheme, case, and trailing slash must all match. `/api/auth/callback/google` and `/api/auth/callback/google/` are two different redirect_uris to Google, and so are `/Callback` and `/callback`.

Fix: match the path exactly, including case and the presence or absence of a trailing slash. django-allauth's callback path is fixed with a trailing slash, `/accounts/google/login/callback/`; most other stacks have none.




05
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  A query string or a fragment on the redirect_uri
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A redirect_uri can't carry a query string or a fragment. Google's validation rejects both outright: no `?state=...` and no `#section`, even if your app would otherwise strip it before use.

Fix: send the bare callback URL only. Any state or code your app needs after the redirect belongs in OAuth's own `state` parameter, never appended to redirect_uri.




06
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  NextAuth, Supabase, Firebase, and Django each expect a different callback
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Each library computes its own redirect_uri, and the value Google needs depends on which one you use:


  - NextAuth v4 / Auth.js v5: &amp;lt;origin&amp;gt;/api/auth/callback/google by default (Qwik and SvelteKit use /auth/callback/google instead; if you set a custom basePath, replace /api/auth with it).

  - Supabase: https://&amp;lt;project-ref&amp;gt;.supabase.co/auth/v1/callback, or your custom auth domain if you've set one. This is Supabase's own callback, not your app's URL: Google redirects to Supabase first, and Supabase redirects to your app second.

  - Firebase: https://&amp;lt;authDomain&amp;gt;/__/auth/handler; the trailing /__/auth/handler is required, and authDomain defaults to &amp;lt;project-id&amp;gt;.firebaseapp.com.

  - django-allauth: &amp;lt;origin&amp;gt;/accounts/google/login/callback/, a fixed path regardless of your own URLconf.


A frequent mistake with Supabase and Firebase specifically: registering your app's own URL instead of the provider's. Fix it by pointing Authorized redirect URIs at the provider's callback, not your app.




07
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  Native and command-line apps need a different client type
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Native and CLI flows don't use a Web application OAuth client, and their redirect_uri looks different. google-auth-oauthlib's `InstalledAppFlow.run_local_server()` uses `http://localhost:&amp;lt;port&amp;gt;/` on a Desktop client, which accepts any loopback port without pre-registering it. `gcloud auth application-default login` with a custom `--client-id-file` runs its listener on the fixed port 8085, `http://localhost:8085/`. Expo's `makeRedirectUri()` returns a custom scheme in a development build, or an `exp://` URL in Expo Go, which Google can't register at all; test with a development build instead.

A custom scheme, anything other than http or https, only works with an Android, iOS, or Desktop client type. Pasting it into a Web application client's redirect URIs produces this same error.




08
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  An invisible character you can't see
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A trailing space or a zero-width character, picked up from a copy-paste, looks identical to the correct redirect_uri but fails Google's exact match. Google's validation explicitly rejects non-printable characters in a redirect_uri.

Fix: retype the value instead of pasting it, or paste it into a plain-text editor first, where a stray space or zero-width character becomes visible.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  - Compare byte-for-byte: scheme (http vs https), host (www vs apex, localhost vs 127.0.0.1), port, and path, including trailing slash and letter case.

  - Confirm there is no query string or fragment on the redirect_uri.

  - Confirm you are editing the OAuth 2.0 Client ID your app's client ID and secret actually reference, and the right Google Cloud project if you have more than one.

  - Add the exact value to Authorized redirect URIs on that client.

  - Re-check after every deploy: Google Cloud Console and your environment variables drift independently of each other.

  - Just saved a change? Google says propagation can take a few minutes up to a few hours.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  Check it in 30 seconds
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Paste the redirect_uri from the error, your Authorized redirect URIs, and your stack into the [Google OAuth redirect_uri_mismatch Doctor](https://arling.sk/google-oauth-redirect-doctor/) to see the exact difference and the fix.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  - Google Identity, "OAuth 2.0 for Web Server Applications: redirect URI validation rules": developers.google.com/identity/protocols/oauth2/web-server#uri-validation

  - Google Identity, "OAuth 2.0 for Desktop and Mobile Apps": developers.google.com/identity/protocols/oauth2/native-app

  - Auth.js, "Google provider": authjs.dev/getting-started/providers/google

  - Supabase, "Login with Google": supabase.com/docs/guides/auth/social-login/auth-google

  - Firebase, "Redirect best practices for authentication": firebase.google.com/docs/auth/web/redirect-best-practices

  - django-allauth, "Google provider": docs.allauth.org/en/latest/socialaccount/providers/google.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;




&lt;p&gt;Originally published at &lt;a href="https://arling.sk/notes/google-redirect-uri-mismatch/" rel="noopener noreferrer"&gt;arling.sk&lt;/a&gt;. Written by Andrej Lauko with AI assistance; every rule links to the official documentation it comes from.&lt;/p&gt;

</description>
      <category>oauth</category>
      <category>google</category>
      <category>nextjs</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Universal links open Safari instead of your Expo app: AASA, assetlinks and app.json checklist</title>
      <dc:creator>ARLing</dc:creator>
      <pubDate>Fri, 04 Sep 2026 14:54:41 +0000</pubDate>
      <link>https://dev.to/arling/universal-links-open-safari-instead-of-your-expo-app-aasa-assetlinks-and-appjson-checklist-ni6</link>
      <guid>https://dev.to/arling/universal-links-open-safari-instead-of-your-expo-app-aasa-assetlinks-and-appjson-checklist-ni6</guid>
      <description>&lt;p&gt;Notes&lt;/p&gt;

&lt;p&gt;Universal links open Safari instead of your Expo app: AASA, assetlinks and app.json checklist&lt;br&gt;
  Andrej Lauko, ARLing · 6 September 2026&lt;/p&gt;

&lt;p&gt;You add &lt;code&gt;associatedDomains&lt;/code&gt; or &lt;code&gt;intentFilters&lt;/code&gt; to &lt;code&gt;app.json&lt;/code&gt;, build the app, and tap a &lt;code&gt;https://yourapp.com/...&lt;/code&gt; link from Messages, Notes, or another app to test it. Instead of opening your Expo app, the link opens in Safari on iOS or in Chrome on Android, with no error anywhere: no crash, no console warning. On Android, &lt;code&gt;adb shell pm get-app-links &amp;lt;package&amp;gt;&lt;/code&gt; often gives the only clue, showing &lt;code&gt;legacy_failure&lt;/code&gt; under "Domain verification state" instead of &lt;code&gt;verified&lt;/code&gt;. Universal Links and App Links are not deep links: iOS and Android will only hand the app a &lt;code&gt;https://&lt;/code&gt; link after independently verifying, at install time, that you actually own the domain. That verification has several separate places to get wrong, and each one fails silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. You're testing in Expo Go
&lt;/h2&gt;

&lt;p&gt;Universal Links and App Links are checked against the installed app's bundle ID and signing key. Expo Go is itself the installed app, so it can never claim your bundle ID. Expo's own linking guide says it plainly: "Support for incoming links in Expo Go is limited. We recommend using Development builds to test your app's linking strategies." Tapping the link in Expo Go just opens the browser, no matter how correct your AASA file and app.json are.&lt;/p&gt;

&lt;p&gt;Fix: test with a development build (&lt;code&gt;npx expo run:ios&lt;/code&gt;, &lt;code&gt;npx expo run:android&lt;/code&gt;, or an EAS dev-client build), or a standalone/production build.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. iOS associatedDomains has the wrong shape
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;app.json&lt;/code&gt;, &lt;code&gt;expo.ios.associatedDomains&lt;/code&gt; entries must be exactly &lt;code&gt;applinks:&amp;lt;domain&amp;gt;&lt;/code&gt;: no &lt;code&gt;https://&lt;/code&gt; prefix, no path after the domain. Expo's docs call the protocol prefix out directly: "Make sure to follow Apple's specified format and not include the protocol (https) in your URL. This is a common mistake that will result in the universal links not working." An entry like &lt;code&gt;https://example.com&lt;/code&gt; or &lt;code&gt;applinks:example.com/app&lt;/code&gt; is silently ignored by iOS.&lt;/p&gt;

&lt;p&gt;{&lt;br&gt;
  "expo": {&lt;br&gt;
    "ios": {&lt;br&gt;
      "associatedDomains": ["applinks:example.com"]&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The apple-app-site-association file doesn't match, or is redirected
&lt;/h2&gt;

&lt;p&gt;The AASA file hosted at &lt;code&gt;/.well-known/apple-app-site-association&lt;/code&gt; needs an &lt;code&gt;applinks.details[]&lt;/code&gt; entry whose &lt;code&gt;appID&lt;/code&gt; (or &lt;code&gt;appIDs&lt;/code&gt;) is exactly &lt;code&gt;&amp;lt;Apple Team ID&amp;gt;.&amp;lt;Bundle Identifier&amp;gt;&lt;/code&gt;, for example &lt;code&gt;ABCDE12345.com.example.app&lt;/code&gt;. iOS requires an exact string match: one typo and the app is never offered the link. Two hosting mistakes cause the same silent failure. First, a 301 or 302 redirect on the AASA URL: Apple's own debugging guide is explicit that a "HTTP redirect... is not supported when hosting the AASA file," so a domain that forwards to another domain must host its own copy instead. Second, the file must be served over HTTPS with &lt;code&gt;Content-Type: application/json&lt;/code&gt; and no BOM at the start.&lt;/p&gt;

&lt;p&gt;{&lt;br&gt;
  "applinks": {&lt;br&gt;
    "apps": [],&lt;br&gt;
    "details": [&lt;br&gt;
      { "appID": "ABCDE12345.com.example.app", "paths": ["/product/*"] }&lt;br&gt;
    ]&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;h2&gt;
  
  
  4. assetlinks.json has the wrong shape, or the wrong fingerprint
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;assetlinks.json&lt;/code&gt; at &lt;code&gt;/.well-known/assetlinks.json&lt;/code&gt; must be a JSON array, even for one app: a bare object at the top level fails verification. Each entry needs &lt;code&gt;relation: ["delegate_permission/common.handle_all_urls"]&lt;/code&gt; and &lt;code&gt;target.namespace: "android_app"&lt;/code&gt;. The most common reason a config that looks correct still fails on-device: the listed &lt;code&gt;sha256_cert_fingerprints&lt;/code&gt; come from a local debug keystore, not from the actual EAS build. Get the right one with &lt;code&gt;eas credentials -p android&lt;/code&gt;, selecting the build profile you're testing, then "SHA256 Fingerprint".&lt;/p&gt;

&lt;p&gt;[&lt;br&gt;
  {&lt;br&gt;
    "relation": ["delegate_permission/common.handle_all_urls"],&lt;br&gt;
    "target": {&lt;br&gt;
      "namespace": "android_app",&lt;br&gt;
      "package_name": "com.example.myapp",&lt;br&gt;
      "sha256_cert_fingerprints": ["14:6D:E9:..."]&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
]&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Android intentFilters is missing autoVerify
&lt;/h2&gt;

&lt;p&gt;Without &lt;code&gt;autoVerify: true&lt;/code&gt; on the intent-filter, Android treats the link as an ordinary, unverified filter: the browser's "Open with" chooser wins instead of your app opening directly. Expo's Android linking guide states it as a requirement, not an option: "Specifying autoVerify is required for Android App Links to work correctly." The filter also needs &lt;code&gt;scheme: "https"&lt;/code&gt; and both the &lt;code&gt;BROWSABLE&lt;/code&gt; and &lt;code&gt;DEFAULT&lt;/code&gt; categories.&lt;/p&gt;

&lt;p&gt;{&lt;br&gt;
  "expo": {&lt;br&gt;
    "android": {&lt;br&gt;
      "intentFilters": [&lt;br&gt;
        {&lt;br&gt;
          "action": "VIEW",&lt;br&gt;
          "autoVerify": true,&lt;br&gt;
          "data": [{ "scheme": "https", "host": "example.com", "pathPrefix": "/product" }],&lt;br&gt;
          "category": ["BROWSABLE", "DEFAULT"]&lt;br&gt;
        }&lt;br&gt;
      ]&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The path pattern doesn't actually cover the link you tested
&lt;/h2&gt;

&lt;p&gt;Both platforms match paths differently than most people expect. In the AASA file's &lt;code&gt;paths&lt;/code&gt; or &lt;code&gt;components&lt;/code&gt;, &lt;code&gt;*&lt;/code&gt; matches a run of characters but never crosses a &lt;code&gt;/&lt;/code&gt; or &lt;code&gt;.&lt;/code&gt;: per Apple's TN3155 guide, &lt;code&gt;/product/*&lt;/code&gt; matches &lt;code&gt;/product/42&lt;/code&gt; but not &lt;code&gt;/product/42/reviews&lt;/code&gt;. On Android, an intent-filter's &lt;code&gt;pathPrefix&lt;/code&gt; is a literal string prefix, not a wildcard pattern, so &lt;code&gt;pathPrefix: "/product/42"&lt;/code&gt; won't cover &lt;code&gt;/product/43&lt;/code&gt;. A link that worked for one test path can fail for the next one for this reason alone.&lt;/p&gt;

&lt;p&gt;Fix: pick a pattern that actually starts with, or matches, every path you intend to open, not just the one you tested first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Confirm you tested on a development or standalone build, never Expo Go.

- curl -I https://yourdomain/.well-known/apple-app-site-association: expect HTTP 200, no redirect, Content-Type: application/json.

- curl https://yourdomain/.well-known/assetlinks.json: valid JSON array, correct package_name and fingerprint.

- Reinstall the app after changing the AASA file. Apple's CDN fetches and caches it per device at install time, so an edit doesn't reach an already-installed app on its own.

- adb shell pm get-app-links &amp;lt;package&amp;gt;: look for verified under "Domain verification state," not legacy_failure or none.

- On a Mac: swcutil dl -d &amp;lt;domain&amp;gt; shows what Apple's CDN actually cached; swcutil verify -d &amp;lt;domain&amp;gt; -j &amp;lt;file&amp;gt; -u &amp;lt;url&amp;gt; checks one URL against it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  Check it in 30 seconds
&lt;/h2&gt;

&lt;p&gt;Paste your app.json, apple-app-site-association and assetlinks.json into the &lt;a href="https://arling.sk/expo-universal-links-doctor/" rel="noopener noreferrer"&gt;Expo Universal Links Doctor&lt;/a&gt; and it points at the exact mismatch.&lt;/p&gt;
&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Expo, iOS Universal Links: docs.expo.dev/linking/ios-universal-links

- Expo, Android App Links: docs.expo.dev/linking/android-app-links

- Expo, Linking overview (Expo Go limitation): docs.expo.dev/linking/overview

- Apple, Supporting associated domains: developer.apple.com/documentation/xcode/supporting-associated-domains

- Apple, TN3155: Debugging universal links: developer.apple.com/documentation/technotes/tn3155-debugging-universal-links

- Android, Verify Android App Links: developer.android.com/training/app-links/verify-android-applinks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Andrej Lauko, ARLing · 6 September 2026&lt;/p&gt;




&lt;p&gt;Originally published at &lt;a href="https://arling.sk/notes/expo-universal-links-open-safari/" rel="noopener noreferrer"&gt;arling.sk&lt;/a&gt;. Written by Andrej Lauko with AI assistance; every rule links to the official documentation it comes from.&lt;/p&gt;

</description>
      <category>expo</category>
      <category>reactnative</category>
      <category>ios</category>
      <category>android</category>
    </item>
    <item>
      <title>Expo + Supabase: why the OAuth deep link never comes back to the app</title>
      <dc:creator>ARLing</dc:creator>
      <pubDate>Fri, 04 Sep 2026 14:54:40 +0000</pubDate>
      <link>https://dev.to/arling/expo-supabase-why-the-oauth-deep-link-never-comes-back-to-the-app-538n</link>
      <guid>https://dev.to/arling/expo-supabase-why-the-oauth-deep-link-never-comes-back-to-the-app-538n</guid>
      <description>&lt;p&gt;Notes&lt;/p&gt;

&lt;p&gt;Expo + Supabase: why the OAuth deep link never comes back to the app&lt;br&gt;
  Andrej Lauko, ARLing · 6 September 2026&lt;/p&gt;

&lt;p&gt;You call &lt;code&gt;supabase.auth.signInWithOAuth()&lt;/code&gt; in an Expo app, the browser opens, the user signs in with Google or another provider, and then the app never gets control back. The browser tab stays open, or it closes and you are still looking at the sign-in screen. Sometimes it lands on &lt;code&gt;http://localhost:3000&lt;/code&gt; instead. In almost every report, the cause is that the URL your Expo runtime actually produces from &lt;code&gt;makeRedirectUri()&lt;/code&gt; does not match what you hardcoded, what Supabase's redirect allow-list expects, or what the OAuth provider's console has on file.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. makeRedirectUri() doesn't return what you hardcoded
&lt;/h2&gt;

&lt;p&gt;Most integrations start with a hardcoded string, &lt;code&gt;redirectTo: 'myapp://auth-callback'&lt;/code&gt;, passed straight into &lt;code&gt;signInWithOAuth()&lt;/code&gt;. That string has to match, character for character, whatever &lt;code&gt;makeRedirectUri()&lt;/code&gt; produces for the runtime the app is actually running in. It never matches every runtime at once, because Expo computes a different value depending on the environment:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- A development build or a standalone build uses your custom scheme directly: scheme://path, no leading slash on the path.

- Expo Go ignores your scheme and always uses exp://&amp;lt;dev-server-host&amp;gt;:&amp;lt;port&amp;gt;/--/path.

- Web uses your site's own URL plus the path.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Call &lt;code&gt;makeRedirectUri({ scheme, path })&lt;/code&gt; at runtime and pass its result into &lt;code&gt;redirectTo&lt;/code&gt;, instead of hardcoding a string. One more thing it is picky about: a path with a leading slash, &lt;code&gt;/auth-callback&lt;/code&gt; instead of &lt;code&gt;auth-callback&lt;/code&gt;, produces a stray extra slash in the final URL, for example &lt;code&gt;myapp:///auth-callback&lt;/code&gt;. Strip leading and trailing slashes from the path before you pass it in.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. The scheme itself has to be valid, or nothing can open the app
&lt;/h2&gt;

&lt;p&gt;A native redirect only works if &lt;code&gt;expo.scheme&lt;/code&gt; in &lt;code&gt;app.json&lt;/code&gt; is a real custom URL scheme: lowercase letters, digits, &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;-&lt;/code&gt;, &lt;code&gt;.&lt;/code&gt;, starting with a letter, no spaces. &lt;code&gt;http&lt;/code&gt; and &lt;code&gt;https&lt;/code&gt; cannot be used here: they are reserved, and the OS will not hand control to your app for them. If the scheme is empty or invalid, &lt;code&gt;makeRedirectUri()&lt;/code&gt; still returns a string, but no app on the device claims it, so the browser has nowhere to send the user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"expo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scheme"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"myapp"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm that value actually reached the native build. A stale development build or standalone binary can keep an old scheme after &lt;code&gt;app.json&lt;/code&gt; changes, until you rebuild it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The redirect isn't on Supabase's allow-list
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;signInWithOAuth()&lt;/code&gt; sends &lt;code&gt;redirectTo&lt;/code&gt; to Supabase, which checks it against Authentication → URL Configuration → Redirect URLs before it lets the browser go anywhere near it. No match, no redirect: Supabase falls back to the Site URL instead, silently.&lt;/p&gt;

&lt;p&gt;The allow-list uses glob patterns, and &lt;code&gt;.&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt; count as separator characters:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- * matches a run of characters that are not . or /

- ** matches any run of characters, including . and /

- ? matches exactly one non-separator character
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;An entry like &lt;code&gt;myapp://auth-callback&lt;/code&gt; only matches that one exact path. Add &lt;code&gt;myapp://**&lt;/code&gt; instead, so every path under your scheme stays covered as the app grows. If you also test in Expo Go, allow-list its dev URL separately: Expo Go never uses your custom scheme.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Supabase Site URL still points to localhost
&lt;/h2&gt;

&lt;p&gt;Site URL is not only the default for password-reset emails: it is where Supabase sends every rejected redirect, in every environment, including production. If Site URL is still &lt;code&gt;http://localhost:8081&lt;/code&gt; from early development, a redirect mismatch anywhere on this page quietly lands a production user on a URL their phone cannot reach, and the app never reopens.&lt;/p&gt;

&lt;p&gt;Set Site URL, in Supabase → Authentication → URL Configuration, to your actual production URL, and let the redirect allow-list, not Site URL, decide where a given sign-in goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The provider console is missing the exact Supabase callback
&lt;/h2&gt;

&lt;p&gt;Before the browser reaches your app, the OAuth provider redirects back to Supabase first, at a fixed URL: &lt;code&gt;https://&amp;lt;project-ref&amp;gt;.supabase.co/auth/v1/callback&lt;/code&gt;, shown on that provider's page in the Supabase dashboard. Unlike Supabase's own allow-list, providers do not accept wildcards here. Google Cloud Console's Authorized redirect URIs, the Apple Services ID's Return URLs, and the GitHub OAuth App's Authorization callback URL all need that exact string, with no trailing slash added.&lt;/p&gt;

&lt;p&gt;A missing or mistyped callback here fails before your app's own redirect logic ever runs: the user sees an error page from the provider, not from Supabase or from your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Native code still needs skipBrowserRedirect, PKCE and exchangeCodeForSession
&lt;/h2&gt;

&lt;p&gt;Three more settings decide whether a correctly matched redirect turns into an actual session on native:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Pass skipBrowserRedirect: true in the options to signInWithOAuth(). Without it, supabase-js tries to issue a browser redirect the way it would on web; on native, you open the URL yourself, typically with expo-web-browser.

- Use flowType: 'pkce' when creating the client. The implicit flow returns tokens in a URL fragment, and fragments do not always survive a deep link.

- Call exchangeCodeForSession(url) in the Linking listener that receives the deep link. PKCE's callback carries a code parameter, not a session: nothing exchanges it unless your code does.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Skip any one of the three and the URL can come back to the app correctly and still leave the user signed out.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Checklist
&lt;/h2&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Build redirectTo with makeRedirectUri({ scheme, path }), never a hardcoded string.

- Confirm expo.scheme in app.json is lowercase, starts with a letter, and is not http/https.

- Strip leading and trailing slashes from the path you pass to makeRedirectUri.

- Add myapp://** to Supabase → Authentication → URL Configuration → Redirect URLs.

- Allow-list the exp:// dev URL too if you test in Expo Go.

- Set Site URL to your production URL, not localhost.

- Add the exact https://&amp;lt;project-ref&amp;gt;.supabase.co/auth/v1/callback to the provider's console.

- Set flowType: 'pkce' and skipBrowserRedirect: true, and call exchangeCodeForSession(url) in the deep-link handler.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  8. Check it in 30 seconds
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Paste your scheme, path, Site URL, redirect allow-list and provider callback into the [Redirect Doctor for Expo and Supabase](https://arling.sk/expo-supabase-auth-doctor/) and it lists exactly which of the above does not match, for the runtime you pick.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  9. Sources
&lt;/h2&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Expo docs: AuthSession, makeRedirectUri()

- Expo docs: Linking, deep link structure

- Supabase docs: Redirect URLs, Site URL fallback and allow-list glob syntax

- Supabase docs: Native mobile deep linking

- Supabase docs: Google sign-in, callback URL and PKCE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;




&lt;p&gt;Originally published at &lt;a href="https://arling.sk/notes/expo-supabase-deep-link-not-returning/" rel="noopener noreferrer"&gt;arling.sk&lt;/a&gt;. Written by Andrej Lauko with AI assistance; every rule links to the official documentation it comes from.&lt;/p&gt;

</description>
      <category>expo</category>
      <category>reactnative</category>
      <category>supabase</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
