<?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: Boxcar Kits</title>
    <description>The latest articles on DEV Community by Boxcar Kits (@boxcarkits).</description>
    <link>https://dev.to/boxcarkits</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%2F4186990%2Fb2197c62-fd5c-48c3-b5f8-3cf8eb42aa9a.png</url>
      <title>DEV Community: Boxcar Kits</title>
      <link>https://dev.to/boxcarkits</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/boxcarkits"/>
    <language>en</language>
    <item>
      <title>I built a tested Stripe + Supabase starter so I'd never wire billing again</title>
      <dc:creator>Boxcar Kits</dc:creator>
      <pubDate>Fri, 16 Oct 2026 18:42:12 +0000</pubDate>
      <link>https://dev.to/boxcarkits/i-built-a-tested-stripe-supabase-starter-so-id-never-wire-billing-again-3mhm</link>
      <guid>https://dev.to/boxcarkits/i-built-a-tested-stripe-supabase-starter-so-id-never-wire-billing-again-3mhm</guid>
      <description>&lt;p&gt;Every paid app needs the same boring parts: sign-in, checkout, a webhook that turns access on and off, a billing page, and account deletion that doesn't leave a live subscription behind. I got tired of rebuilding them, so I packaged them up as Express Track.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's inside:&lt;/strong&gt; React 19, TypeScript and Vite. Supabase sign-in (email, magic link, Google) with row-level security. Stripe Checkout for monthly, yearly and lifetime plans. A signed webhook that ignores duplicate events. The Stripe customer portal. Deploy configs for Vercel and Netlify.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it's tested:&lt;/strong&gt; 186 unit tests, 43 database security tests and Playwright smoke tests, plus a full run in Stripe test mode (subscribe, cancel, refund, failed payment, duplicate events).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lessons from testing against real Stripe:&lt;/strong&gt; events can show up for users who were already deleted, and React StrictMode will quietly cancel your success-page polling. Both are fixed in the kit.&lt;/p&gt;

&lt;p&gt;It's $79 with a 30-day refund: &lt;a href="https://boxcarkits.gumroad.com/l/express-track" rel="noopener noreferrer"&gt;https://boxcarkits.gumroad.com/l/express-track&lt;/a&gt;. Questions welcome!&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>react</category>
      <category>stripe</category>
      <category>supabase</category>
    </item>
    <item>
      <title>How I built a spreadsheet that does Etsy fee math (formulas inside)</title>
      <dc:creator>Boxcar Kits</dc:creator>
      <pubDate>Thu, 15 Oct 2026 15:47:46 +0000</pubDate>
      <link>https://dev.to/boxcarkits/how-i-built-a-spreadsheet-that-does-etsy-fee-math-formulas-inside-5525</link>
      <guid>https://dev.to/boxcarkits/how-i-built-a-spreadsheet-that-does-etsy-fee-math-formulas-inside-5525</guid>
      <description>&lt;p&gt;Etsy sellers ask "what do I actually keep from a sale?" all the time. The answer is a stack of fees, and some are charged on more than the item price. I built an Excel + Google Sheets workbook that answers it. Here is the formula logic, so you can copy it into your own sheet.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;These are US fee rates I checked against Etsy's fee pages on Oct 13, 2026. Fees change, so put them in cells, not in formulas. None of this is tax or financial advice.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. Put every rate in a Settings tab
&lt;/h2&gt;

&lt;p&gt;Each rate gets its own cell:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cell&lt;/th&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;US default&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;B3&lt;/td&gt;
&lt;td&gt;Listing fee&lt;/td&gt;
&lt;td&gt;$0.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B4&lt;/td&gt;
&lt;td&gt;Transaction fee&lt;/td&gt;
&lt;td&gt;6.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B5&lt;/td&gt;
&lt;td&gt;Payment processing %&lt;/td&gt;
&lt;td&gt;3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B6&lt;/td&gt;
&lt;td&gt;Processing fixed fee&lt;/td&gt;
&lt;td&gt;$0.25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B7&lt;/td&gt;
&lt;td&gt;Offsite Ads fee&lt;/td&gt;
&lt;td&gt;15% (12% for shops over $10k/yr)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B10&lt;/td&gt;
&lt;td&gt;Avg sales tax rate&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B11&lt;/td&gt;
&lt;td&gt;Regulatory operating fee&lt;/td&gt;
&lt;td&gt;0% in US&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B12&lt;/td&gt;
&lt;td&gt;Offsite Ads cap per order&lt;/td&gt;
&lt;td&gt;$100&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If Etsy changes a fee, you edit one cell and every formula updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Order revenue
&lt;/h2&gt;

&lt;p&gt;Etsy charges the transaction fee on item price &lt;strong&gt;plus shipping&lt;/strong&gt;, so the base needs both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Order revenue = price * qty + shipping
=B3*B5+B4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Each fee
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Listing fee        =Settings!$B$3*B5
Transaction fee    =ROUND(B12*(Settings!$B$4+Settings!$B$11),2)
Payment processing =ROUND(B12*(1+Settings!$B$10)*Settings!$B$5+Settings!$B$6,2)
Offsite Ads        =IF(UPPER(B9)="Y",MIN(Settings!$B$12,ROUND(B12*Settings!$B$7,2)),0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some things I learned along the way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Processing is charged on sales tax too.&lt;/strong&gt; The buyer pays tax, but Etsy's 3% applies to the full total. The &lt;code&gt;(1+B10)&lt;/code&gt; factor handles that. Leave it at 0% if you don't know your average.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offsite Ads has a cap.&lt;/strong&gt; &lt;code&gt;MIN()&lt;/code&gt; stops the fee at $100 per order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Round each fee on its own&lt;/strong&gt; with &lt;code&gt;ROUND(...,2)&lt;/code&gt;, the way a statement does. Otherwise your totals drift a few cents from what Etsy shows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UPPER()&lt;/code&gt; on the Y/N cell&lt;/strong&gt; means "y" works as well as "Y".&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Net profit and margin
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Total fees  =SUM(B13:B16)
Costs       =B6*B5+B7+B8        (materials*qty + packaging + postage)
Net profit  =B12-B17-B18
Margin      =IFERROR(B19/B12,0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example: a $24 item with $4.99 shipping, $5.50 materials, $0.80 packaging and $4.50 postage, not from Offsite Ads. Fees come to &lt;strong&gt;$3.20&lt;/strong&gt;: $0.20 listing, $1.88 transaction, $1.12 processing. Net profit is &lt;strong&gt;$14.99&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Working backwards: break-even and target price
&lt;/h2&gt;

&lt;p&gt;This was the most useful part. Profit is zero when:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;price*q + ship = costs + listing + fixed + (price*q + ship) * pct_fees
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pct_fees&lt;/code&gt; is everything charged as a percentage: transaction, regulatory, processing (adjusted for tax) and Offsite Ads if it applies. Solve for price:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=ROUND(((B18+B13+Settings!$B$6)
  /(1-Settings!$B$4-Settings!$B$11-Settings!$B$5*(1+Settings!$B$10)
     -IF(UPPER(B9)="Y",Settings!$B$7,0))
  -B4)/B5,2)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a target margin &lt;code&gt;m&lt;/code&gt;, take &lt;code&gt;m&lt;/code&gt; away from the denominator too (&lt;code&gt;...-B10&lt;/code&gt; in the sheet, where B10 is the target margin). Same example: break-even item price is &lt;strong&gt;$7.44&lt;/strong&gt;, and a 35% margin needs &lt;strong&gt;$15.28&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;One caveat: the closed form ignores the Offsite Ads cap. It only matters on orders over about $667, so I left it out.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Roll it up with SUMIFS (no dynamic arrays)
&lt;/h2&gt;

&lt;p&gt;I wanted it to work in Excel 2010 as well as Google Sheets, so I skipped &lt;code&gt;FILTER&lt;/code&gt;/&lt;code&gt;LET&lt;/code&gt;. The Sales Log has a helper column Q holding &lt;code&gt;YYYY-MM&lt;/code&gt;. The dashboard then uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Orders   =COUNTIFS('Sales Log'!$Q:$Q,A5)
Revenue  =SUMIFS('Sales Log'!$I:$I,'Sales Log'!$Q:$Q,A5)
Fees     =SUMIFS('Sales Log'!$N:$N,'Sales Log'!$Q:$Q,A5)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Products tab works out each SKU's margin after fees and flags anything under 30%:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=IF(E2="","",IF(E2&amp;lt;0.3,"REVIEW PRICING","OK"))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  7. Test the formulas outside Excel
&lt;/h2&gt;

&lt;p&gt;I recalculated the workbook headless in LibreOffice (&lt;code&gt;soffice --headless --convert-to xlsx&lt;/code&gt;), read the cached values back with Python's &lt;code&gt;openpyxl&lt;/code&gt; (&lt;code&gt;data_only=True&lt;/code&gt;), and checked them against a separate Python version of the same math. That caught a few real bugs before anyone bought anything. (Tip: on complex sheets, turn off LibreOffice's threaded calculation. It gave me wrong results once.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Grab it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The finished workbook (Sales Log, Products, Dashboard, Fee Calculator) on Etsy: &lt;a href="https://www.etsy.com/listing/4596086754/etsy-seller-profit-tracker-spreadsheet" rel="noopener noreferrer"&gt;https://www.etsy.com/listing/4596086754/etsy-seller-profit-tracker-spreadsheet&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;My other templates (landlord, debt payoff, wedding, inventory, freelancer, job search): &lt;a href="https://boxcarkits.gumroad.com" rel="noopener noreferrer"&gt;https://boxcarkits.gumroad.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Or just copy the formulas above. That's why they're here.&lt;/p&gt;

</description>
      <category>spreadsheets</category>
      <category>excel</category>
      <category>googlesheets</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>Native Google sign-in with Supabase in a Capacitor Android app (and the bug that cost me a build)</title>
      <dc:creator>Boxcar Kits</dc:creator>
      <pubDate>Thu, 15 Oct 2026 14:31:31 +0000</pubDate>
      <link>https://dev.to/boxcarkits/native-google-sign-in-with-supabase-in-a-capacitor-android-app-and-the-bug-that-cost-me-a-build-gph</link>
      <guid>https://dev.to/boxcarkits/native-google-sign-in-with-supabase-in-a-capacitor-android-app-and-the-bug-that-cost-me-a-build-gph</guid>
      <description>&lt;p&gt;I wanted the normal Android Google sign-in for a Capacitor app: the system account picker, no browser tab, ending in a Supabase session. The flow is simple once it works. The picker gives you a Google ID token, and Supabase checks that token with &lt;code&gt;signInWithIdToken&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Getting it working took longer than I expected. Most of the time went into Google Cloud setup, plus one bug in my own code that only showed up on a phone. Here's the setup that works, then the bug.&lt;/p&gt;

&lt;p&gt;Stack: Capacitor 8, React, TypeScript, &lt;code&gt;@supabase/supabase-js&lt;/code&gt;, and &lt;code&gt;@capgo/capacitor-social-login&lt;/code&gt; 8.5.x.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Google Cloud: one Web client, and Android clients for each signing key
&lt;/h2&gt;

&lt;p&gt;Put everything in one Google Cloud project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OAuth consent screen.&lt;/strong&gt; Choose External. While the app is in &lt;strong&gt;Testing&lt;/strong&gt; mode, only accounts listed under &lt;strong&gt;Audience &amp;gt; Test users&lt;/strong&gt; can sign in. If you test with a Google account that isn't on the list, sign-in fails. Add every account you plan to test with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Web application client.&lt;/strong&gt; This is the client the app actually uses, even on Android. Its client ID goes into your app config, and its secret goes only into Supabase. Add Supabase's callback URL (&lt;code&gt;https://&amp;lt;project-ref&amp;gt;.supabase.co/auth/v1/callback&lt;/code&gt;) as a redirect URI if you also want the browser OAuth fallback for local dev.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Android clients.&lt;/strong&gt; The app never sends these IDs anywhere. Google just checks that the installed app's package name and signing certificate are registered in the same project as the Web client. The catch is that you need one Android client &lt;strong&gt;per certificate that signs an install&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;your debug keystore, for local builds (&lt;code&gt;cd android &amp;amp;&amp;amp; ./gradlew signingReport&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;your upload key, for release builds you sideload (&lt;code&gt;keytool -printcert -jarfile app-release.aab&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;Play app signing key&lt;/strong&gt;, for anything installed from Google Play, including Internal testing. Google re-signs your bundle, so the upload key's SHA-1 doesn't cover these installs. You'll find this one in Play Console under App integrity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use the same package name for all of them. SHA-1 fingerprints are public, so it's fine to paste them anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Supabase: enable the Google provider
&lt;/h2&gt;

&lt;p&gt;In &lt;strong&gt;Authentication &amp;gt; Sign In / Providers &amp;gt; Google&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enable it, and enter the &lt;strong&gt;Web&lt;/strong&gt; client ID and secret.&lt;/li&gt;
&lt;li&gt;Make sure the Web client ID is in the authorized client IDs list. The ID token's audience is the Web client, and Supabase rejects tokens whose audience it doesn't know.&lt;/li&gt;
&lt;li&gt;Leave &lt;strong&gt;Skip nonce check&lt;/strong&gt; off. We'll send a nonce properly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Initialize the plugin with the Web client ID
&lt;/h2&gt;

&lt;p&gt;The plugin's &lt;code&gt;online&lt;/code&gt; mode returns an ID token, which is what Supabase needs. Load the plugin lazily so that web builds don't pull it in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SocialLoginPlugin&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capgo/capacitor-social-login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pluginPromise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SocialLoginPlugin&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getPlugin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;webClientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;pluginPromise&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capgo/capacitor-social-login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;SocialLogin&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="c1"&gt;// The WEB client ID, not the Android one.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;SocialLogin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;google&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;webClientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;online&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;SocialLogin&lt;/span&gt;&lt;span class="p"&gt;;&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;pluginPromise&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;Passing the Android client ID here is a classic mistake. On Android, &lt;code&gt;webClientId&lt;/code&gt; becomes the server client ID that Credential Manager requests a token for.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The nonce: hash for Google, raw for Supabase
&lt;/h2&gt;

&lt;p&gt;Supabase checks the nonce to stop replayed ID tokens. The rule that tripped me up: Google gets the &lt;strong&gt;SHA-256 hex digest&lt;/strong&gt; of the nonce, and Supabase gets the &lt;strong&gt;raw&lt;/strong&gt; value. Supabase hashes the raw value itself and compares it with the &lt;code&gt;nonce&lt;/code&gt; claim in the token.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toHex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;Array&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;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&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;createNoncePair&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;random&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&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;rawNonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toHex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;random&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;digest&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawNonce&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rawNonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;nonceDigest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;toHex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;))&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;If you send the same value to both, or send a nonce on only one side, Supabase rejects the sign-in with a nonce error.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Sign in
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rawNonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nonceDigest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createNoncePair&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;plugin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getPlugin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;WEB_CLIENT_ID&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;res&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;plugin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&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;nonce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nonceDigest&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// no scopes, see below&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &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="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responseType&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;online&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No ID token&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&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;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;signInWithIdToken&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;token&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="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rawNonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some errors deserve their own handling, so the user doesn't just see "something went wrong":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cancellation.&lt;/strong&gt; The user closed the picker. Treat it as a normal outcome and show no error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;[28444] Developer console is not set up correctly&lt;/code&gt;.&lt;/strong&gt; This almost always means the package name or SHA-1 of the installed build isn't registered, or the clients are in different Google Cloud projects. Show a message pointing at the setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No credentials available.&lt;/strong&gt; The phone has no Google account on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bug that cost me a build
&lt;/h2&gt;

&lt;p&gt;My first version of the login call looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;plugin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&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;scopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nonceDigest&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;It looks harmless. You want the email and profile, so you ask for them. On a real phone, the app installed from Play Internal testing showed my generic "Something went wrong" message as soon as I tapped the button.&lt;/p&gt;

&lt;p&gt;My first guess was SHA-1, since that's what usually breaks. But the Supabase auth logs showed no token request from the phone at all. So the failure happened before Supabase ever got involved.&lt;/p&gt;

&lt;p&gt;The answer was in the plugin's Android source. If you pass a &lt;code&gt;scopes&lt;/code&gt; array, the plugin checks whether your &lt;code&gt;MainActivity&lt;/code&gt; implements &lt;code&gt;ModifiedMainActivityForSocialLoginPlugin&lt;/code&gt;. If it doesn't, it rejects right away with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You CANNOT use scopes without modifying the main activity. Please follow the docs!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That interface is meant for requesting extra Google API scopes through a separate authorization step. A plain sign-in doesn't need it. The plugin already adds &lt;code&gt;openid&lt;/code&gt;, &lt;code&gt;userinfo.email&lt;/code&gt; and &lt;code&gt;userinfo.profile&lt;/code&gt; by default, and the ID token carries the email and name either way.&lt;/p&gt;

&lt;p&gt;My error mapping only knew about cancellation, the 28444 setup error and the no-account case, so this message landed in the "unknown" bucket. Worse, my fallback logged only &lt;code&gt;error.name&lt;/code&gt;, which threw away the one sentence that would have explained everything.&lt;/p&gt;

&lt;p&gt;The fix was to drop &lt;code&gt;scopes&lt;/code&gt;. I pulled the options into a tiny function so a test could guard it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;googleLoginOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nonceDigest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nonceDigest&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 typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sends no scopes (they make the Android plugin reject)&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;googleLoginOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;abc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toHaveProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scopes&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also changed the unknown-error path to log the plugin's message, with emails and anything that looks like a token redacted and the length capped. The next surprise should take a minute to diagnose, not a rebuild and a re-upload.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Consent screen: every testing account is listed as a test user.&lt;/li&gt;
&lt;li&gt;One Web client. Its ID goes in the app and in Supabase, and its secret goes only in Supabase.&lt;/li&gt;
&lt;li&gt;Android clients with the same package name for debug, upload key and Play app signing key.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;webClientId&lt;/code&gt; is the Web client ID.&lt;/li&gt;
&lt;li&gt;The SHA-256 hex nonce goes to Google, and the raw nonce goes to &lt;code&gt;signInWithIdToken&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;scopes&lt;/code&gt; option unless you have actually changed MainActivity.&lt;/li&gt;
&lt;li&gt;When a sign-in fails on a device, check the Supabase auth logs first. If no request ever reached Supabase, the problem is on the device or in your Google config, not in Supabase.&lt;/li&gt;
&lt;li&gt;Log the plugin's error message, safely redacted, not just its name.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;I packaged this setup (plus RevenueCat, AdMob, and Play release scripts) into a paid starter kit, First Track: &lt;a href="https://boxcarkits.gumroad.com/l/first-track" rel="noopener noreferrer"&gt;https://boxcarkits.gumroad.com/l/first-track&lt;/a&gt;&lt;/p&gt;

</description>
      <category>capacitor</category>
      <category>android</category>
      <category>supabase</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
