<?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: DGIT</title>
    <description>The latest articles on DEV Community by DGIT (@dgit).</description>
    <link>https://dev.to/dgit</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%2F4008294%2F89dffb8f-97d1-4bad-965c-8219c1ad58f1.png</url>
      <title>DEV Community: DGIT</title>
      <link>https://dev.to/dgit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dgit"/>
    <language>en</language>
    <item>
      <title>JWT auth across web and mobile in ASP.NET Core — what I actually built and why</title>
      <dc:creator>DGIT</dc:creator>
      <pubDate>Mon, 29 Jun 2026 20:38:38 +0000</pubDate>
      <link>https://dev.to/dgit/jwt-auth-across-web-and-mobile-in-aspnet-core-what-i-actually-built-and-why-10kd</link>
      <guid>https://dev.to/dgit/jwt-auth-across-web-and-mobile-in-aspnet-core-what-i-actually-built-and-why-10kd</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ddvm3rneuk56jfjnx2t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ddvm3rneuk56jfjnx2t.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every app needs auth. It's the part of a project that's genuinely hard to get right, surprisingly easy to get wrong in subtle ways, and also the part where you look at Auth0 or Clerk and think: maybe I just pay someone else to deal with this.&lt;/p&gt;

&lt;p&gt;I didn't. Here's what I built instead, why, and the parts that tripped me up.&lt;/p&gt;

&lt;p&gt;This is part of a series on &lt;a href="https://gormez.gumroad.com/l/ministack" rel="noopener noreferrer"&gt;MiniStack&lt;/a&gt; — a full-stack mobile boilerplate built on Expo + ASP.NET Core. The &lt;a href="https://dev.to/dgit/how-i-built-a-production-mobile-app-boilerplate-in-two-weeks-without-writing-a-line-of-frontend-code-2moa"&gt;&lt;/a&gt;# covers the full stack and how the whole project came together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not Auth0 or Clerk
&lt;/h2&gt;

&lt;p&gt;Honest answer: familiarity. I've been writing .NET for ten years and I understand how JWT auth works in ASP.NET Core. I didn't want to introduce an external service that manages my users, sits between my app and its database, and adds a pricing conversation when things scale.&lt;/p&gt;

&lt;p&gt;That's a personal call, not a universal one. Auth0 and Clerk are good products. If your team doesn't have strong backend auth experience, reaching for one of them is probably the right move. I just wasn't in that situation, and owning the full implementation felt cleaner to me.&lt;/p&gt;

&lt;p&gt;What I built ended up being fairly standard — a split-token pattern with some decisions around per-device sessions that are worth explaining.&lt;/p&gt;




&lt;h2&gt;
  
  
  The token model
&lt;/h2&gt;

&lt;p&gt;Two tokens. Short-lived access tokens, long-lived refresh tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Access tokens&lt;/strong&gt; live in memory only. Never written to disk, never stored in localStorage, never put in a cookie. They're valid for 60 minutes. When your app starts or the token expires, it gets a new one using the refresh token. If the app is closed, the access token is gone — that's intentional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refresh tokens&lt;/strong&gt; are different. They're persisted — one row per device in a &lt;code&gt;UserRefreshTokens&lt;/code&gt; table — and they're valid for 30 days. Every time a refresh token is used, the old row is deleted and a new one is inserted. This is called token rotation. If a refresh token is stolen and used, the original owner's next request will fail because their token no longer exists.&lt;/p&gt;

&lt;p&gt;The per-device model was a deliberate decision. I see a lot of implementations that store a single refresh token on the user record. The problem: if you log out on your phone, you've ended your session on your laptop too. That's not how any modern app works. One row per device means logout only affects the device that requested it. Password reset, on the other hand, deletes all rows — every device gets logged out, which is the right behaviour after a credential change.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the tokens live on each platform
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On mobile (Expo):&lt;/strong&gt; the refresh token goes into &lt;code&gt;expo-secure-store&lt;/code&gt;, which maps to iOS Keychain on iPhone and Android Keystore on Android. The access token is a module-level variable in the API service — in memory, gone when the app closes. On startup, the app reads the stored refresh token, calls &lt;code&gt;/api/auth/refresh&lt;/code&gt;, and gets a new access token back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On web (Next.js):&lt;/strong&gt; the browser never sees the refresh token directly. There's a small backend-for-frontend layer — three Next.js API routes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;POST /api/set-refresh-token&lt;/code&gt; — stores the refresh token in an httpOnly cookie after login&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /api/refresh&lt;/code&gt; — reads the cookie, calls the ASP.NET Core refresh endpoint, updates the cookie&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /api/clear-refresh-token&lt;/code&gt; — deletes the cookie on logout&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An httpOnly cookie can't be read by JavaScript. If the page has an XSS vulnerability, the refresh token is still safe. The access token — which JavaScript does need — is kept in React state, which means it's gone on page refresh, triggering a silent refresh via the cookie.&lt;/p&gt;




&lt;h2&gt;
  
  
  Google and Apple Sign-In
&lt;/h2&gt;

&lt;p&gt;The mental model that unlocked this for me: &lt;strong&gt;the backend doesn't do an OAuth redirect flow. It just validates a JWT.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Expo's auth libraries handle the actual OAuth dance on-device — opening a browser, redirecting, getting the response back. What comes out the other end is a signed token: an &lt;code&gt;id_token&lt;/code&gt; from Google, an &lt;code&gt;identityToken&lt;/code&gt; from Apple. The mobile app sends that token to the backend. The backend validates the signature against the provider's public keys and extracts the claims. That's it.&lt;/p&gt;

&lt;p&gt;For Google: &lt;code&gt;GoogleJsonWebSignature.ValidateAsync&lt;/code&gt; from the &lt;code&gt;Google.Apis.Auth&lt;/code&gt; package does the heavy lifting. It verifies the signature, checks expiry, and confirms the audience matches your configured client IDs. For Apple: you fetch the JWKS from &lt;code&gt;https://appleid.apple.com/auth/keys&lt;/code&gt; and validate the JWT manually, checking the bundle ID as the audience.&lt;/p&gt;

&lt;p&gt;Both flows end the same way as email/password login: upsert the user, issue an access token and refresh token, return the auth response. OAuth users are marked as email-verified immediately — the provider already verified it.&lt;/p&gt;

&lt;p&gt;One Apple-specific thing worth knowing: &lt;strong&gt;Apple only gives you the user's name on the very first sign-in.&lt;/strong&gt; After that, you get a stable user ID (&lt;code&gt;sub&lt;/code&gt;) but no name. Save the name on that first call or you'll never get it.&lt;/p&gt;

&lt;p&gt;Getting Google Sign-In to work correctly across all Expo platforms took four separate fixes. I'll cover those in detail in the next article — they're specific enough to warrant their own post.&lt;/p&gt;

&lt;p&gt;&lt;a href="" class="article-body-image-wrapper"&gt;&lt;img alt="MiniStack API — auth endpoints in Scalar"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Email verification and password reset
&lt;/h2&gt;

&lt;p&gt;Registration issues a refresh token immediately so the user can start using the app, but sets &lt;code&gt;EmailVerified = false&lt;/code&gt;. A 24-hour verification link goes out by email. OAuth registrations skip this — the provider already verified the address.&lt;/p&gt;

&lt;p&gt;Password reset follows a pattern you've probably seen: &lt;code&gt;POST /api/auth/forgot-password&lt;/code&gt; with an email address always returns 200, regardless of whether the account exists. This prevents user enumeration — an attacker can't use your reset endpoint to discover which emails are registered. The reset token is valid for one hour, and using it deletes every refresh token row for that user. All devices get logged out. If someone reset your password, you want them out everywhere.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rate limiting
&lt;/h2&gt;

&lt;p&gt;Auth endpoints — login, register, forgot-password — are limited to 5 requests per 15 minutes per IP. OAuth endpoints get a slightly higher limit (30 per 15 minutes) since OAuth flows can involve multiple round trips legitimately.&lt;/p&gt;

&lt;p&gt;One implementation detail worth noting: the rate limiter runs as middleware, so it also runs in tests. Integration tests will start failing in confusing ways if you don't disable it in your test environment. The fix is a single environment check in &lt;code&gt;Program.cs&lt;/code&gt; — skip &lt;code&gt;AddRateLimiter&lt;/code&gt; and &lt;code&gt;UseRateLimiter&lt;/code&gt; when the environment is &lt;code&gt;Testing&lt;/code&gt;. Obvious once you've hit it, not obvious before.&lt;/p&gt;




&lt;h2&gt;
  
  
  A few smaller decisions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ClockSkew = TimeSpan.Zero&lt;/code&gt;&lt;/strong&gt; — The default in .NET is a 5-minute tolerance, meaning a token that expired 4 minutes ago is still accepted. That's there to handle clock drift between servers, which is a real concern in some architectures. In this setup there's one backend and tokens should expire precisely when they say they do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CORS via config, not code&lt;/strong&gt; — Origins are configured via &lt;code&gt;appsettings&lt;/code&gt; (&lt;code&gt;Cors:AllowedOrigins&lt;/code&gt;) rather than hardcoded. An empty array falls back to &lt;code&gt;AllowAnyOrigin&lt;/code&gt; for local development, so you're not fighting CORS during development but you're forced to be explicit in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security headers&lt;/strong&gt; — &lt;code&gt;X-Content-Type-Options&lt;/code&gt;, &lt;code&gt;HSTS&lt;/code&gt;, and a few others go out with every response. Nothing exotic, just the basics that scanners and security reviews will flag if they're missing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Configuration
&lt;/h2&gt;

&lt;p&gt;Everything auth-related lives in appsettings and gets injected at startup. JWT secret, issuer, audience, token lifetimes, CORS origins, Google client IDs, Apple bundle ID, email SMTP config. Nothing hardcoded, nothing in the repo — all of it goes into environment variables or a secrets manager in production.&lt;/p&gt;

&lt;p&gt;In development, leaving the SMTP config empty makes the backend log email links to the console instead of sending them. That's useful for testing verification and reset flows without needing a real email service wired up.&lt;/p&gt;




&lt;h2&gt;
  
  
  Is it worth building yourself?
&lt;/h2&gt;

&lt;p&gt;For me, yes. I own the implementation, I understand every part of it, and there's no external dependency on my authentication path.&lt;/p&gt;

&lt;p&gt;The tradeoff is real though. This took time to get right. If I'd reached for Auth0 the auth would have been done in an afternoon. What I built took significantly longer and required knowing enough about JWT, OAuth, and token security to make reasonable decisions along the way.&lt;/p&gt;

&lt;p&gt;The code for all of this is in &lt;a href="https://gormez.gumroad.com/l/ministack" rel="noopener noreferrer"&gt;MiniStack&lt;/a&gt;. The auth architecture is documented in full in &lt;code&gt;AUTH.md&lt;/code&gt; in the repo, including flow diagrams for every auth path and a full configuration reference.&lt;/p&gt;

&lt;p&gt;Questions? &lt;a href="https://github.com/dgormez/ministack-support/discussions" rel="noopener noreferrer"&gt;Open a discussion&lt;/a&gt;.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How I built a production mobile app boilerplate in two weeks without writing a line of frontend code</title>
      <dc:creator>DGIT</dc:creator>
      <pubDate>Mon, 29 Jun 2026 14:28:24 +0000</pubDate>
      <link>https://dev.to/dgit/how-i-built-a-production-mobile-app-boilerplate-in-two-weeks-without-writing-a-line-of-frontend-code-2moa</link>
      <guid>https://dev.to/dgit/how-i-built-a-production-mobile-app-boilerplate-in-two-weeks-without-writing-a-line-of-frontend-code-2moa</guid>
      <description>&lt;h2&gt;
  
  
  How I built a production mobile app boilerplate in two weeks without writing a line of frontend code
&lt;/h2&gt;

&lt;p&gt;Eight months ago my wife and I had twins.&lt;/p&gt;

&lt;p&gt;The baby tracker apps on the App Store weren't designed for multiples. Most were paid. The free ones were missing things. None of them thought about two babies at the same time — one timer, one log, one feeding. So I built my own. TwinLog is a free tracker for twins and multiples ([&lt;a href="https://play.google.com/store/apps/details?id=com.dgit.babytracker%5D" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.dgit.babytracker]&lt;/a&gt;) . My wife uses it every day. It just launched on the Play Store with almost no users and I'm genuinely fine with that — it was built because we needed it, not because I wanted users.&lt;/p&gt;

&lt;p&gt;After shipping it I noticed something obvious in hindsight: every app starts with the same work. Authentication, payments, cloud infrastructure, CI/CD pipelines. Two weeks of scaffolding before you write a single line of what your app actually does. TwinLog has none of that — no auth because I didn't have time, no payments because it's free, no web frontend. I skipped everything non-essential to ship fast.&lt;/p&gt;

&lt;p&gt;But a commercial app can't skip those things. And I wasn't going to rebuild them from scratch every time.&lt;/p&gt;

&lt;p&gt;So I built MiniStack — a full-stack mobile boilerplate — instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Expo for mobile (React Native), Next.js 15 for the web frontend, ASP.NET Core with EF Core for the backend, PostgreSQL for the database. Terraform modules for both GCP and Azure. GitHub Actions for CI/CD. Google and Apple Sign-In, email/password auth, Stripe subscriptions with a free tier, email verification, push notification wiring.&lt;/p&gt;

&lt;p&gt;I'm a .NET developer. Ten years. I don't particularly enjoy frontend — I can do it but it takes me longer and I enjoy it less. Picking ASP.NET Core as the backend wasn't a considered tradeoff, it was just the obvious choice. I know it, it's fast, and I had no reason to relearn deployment tooling and testing patterns in Node just because the frontend uses TypeScript.&lt;/p&gt;

&lt;p&gt;Most React Native starters assume a JavaScript backend. This one doesn't. If you're a Node developer this probably isn't for you. If you're a .NET developer who wants to ship a mobile app without switching ecosystems, keep reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it was actually built
&lt;/h2&gt;

&lt;p&gt;The whole codebase was written by Claude AI. I directed, it implemented.&lt;/p&gt;

&lt;p&gt;I should be specific about what that means, because "written by AI" covers a lot of ground. I didn't paste prompts into a chat and copy code into files. I used Claude Desktop's Cowork tab for the earlier project (TwinLog) — that gives Claude direct access to your repo and it edits files itself, but you still copy-paste and run terminal commands manually. I switched to Claude Code in VS Code quickly. The difference is that Claude Code runs the commands too. You see file diffs appear in your editor in real time. When something breaks you can see exactly what changed and why, which makes course-correcting much faster on a project with dozens of interconnected files.&lt;/p&gt;

&lt;p&gt;What I actually did: described what I wanted, reviewed what came back, made the architectural calls, and corrected direction when something was wrong. I didn't write the implementation. It's the first time I've worked this way on a real project — not a tutorial, not a toy — and it held up.&lt;/p&gt;

&lt;p&gt;It took about two weeks. A couple of hours a day, not every day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auth: custom JWT without Auth0
&lt;/h2&gt;

&lt;p&gt;TwinLog has no authentication. MiniStack has full authentication. The decision to build it ourselves rather than use Auth0, Clerk, or Supabase Auth was mostly about familiarity. I understand .NET JWT auth. I didn't want another external service managing my users or another pricing tier to reason about when my next app scales.&lt;/p&gt;

&lt;p&gt;This is personal preference, not a universal recommendation. Auth0 and Clerk are good products. For teams without strong backend auth experience they're probably the right call.&lt;/p&gt;

&lt;p&gt;The implementation uses a split-token pattern. Access tokens live in memory only, 60-minute lifetime. Refresh tokens are in the database, one row per device, rotated every time they're used. On the web the refresh token sits in an httpOnly cookie managed by a Next.js API route — JavaScript in the browser never touches it. On mobile it goes into expo-secure-store, which maps to iOS Keychain or Android Keystore.&lt;/p&gt;

&lt;p&gt;Each device has its own session. Logging out on your phone doesn't end your session on your laptop.&lt;/p&gt;

&lt;p&gt;Google and Apple Sign-In took a while to get right. The mental model that helped: the backend doesn't do a redirect flow. Expo's libraries handle the OAuth dance on-device and return a signed token — id_token from Google, identityToken from Apple. The backend validates that token against the provider's public keys and issues its own JWT. That's the whole thing. Once I understood that the server was just validating a JWT — something it already knew how to do — it stopped feeling mysterious.&lt;/p&gt;

&lt;p&gt;Getting it working across all platforms was four separate bug fixes. I'll cover them in detail in the Google Sign-In article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure: Terraform because I don't like clicking in portals
&lt;/h2&gt;

&lt;p&gt;My Azure subscription had one month of free credits. GCP offers three. I moved to GCP.&lt;/p&gt;

&lt;p&gt;I didn't want to learn where to click in the GCP console. The portal changes constantly and every tutorial has screenshots of a UI that no longer exists. I wanted something I could run again from scratch without rediscovering each step.&lt;/p&gt;

&lt;p&gt;The goal was simple: terraform init &amp;amp;&amp;amp; terraform apply. Phase 1 is three terminal commands — authenticate, set a project ID, enable billing. After that Terraform provisions everything: Cloud Run service, Artifact Registry, service accounts, IAM roles, Workload Identity Federation so GitHub Actions can deploy without any stored credentials. The deploy workflow pushes a Docker image and Cloud Run updates. No secrets sitting in GitHub settings beyond the ones you actually need.&lt;/p&gt;

&lt;p&gt;Getting there took more iterations than I expected. Workload Identity Federation has ordering dependencies that aren't obvious — the identity pool has to exist before you can grant roles against it, but the service account needs those roles before Cloud Run can pull from Artifact Registry. The Terraform for Azure had its own issues around federated credentials and Container App identity. The infra modules went through several revisions.&lt;/p&gt;

&lt;p&gt;But once it's done it just works. A buyer runs Terraform once, puts a handful of values into GitHub Secrets, and pushes. Every merge to master builds, tests, and deploys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stripe
&lt;/h2&gt;

&lt;p&gt;The demo entity in MiniStack is a habit tracker. Free tier: 3 habits. Pro: unlimited. That's not because anyone buying a boilerplate wants to build a habit tracker — it's because it's a clean way to show subscription gating. The pattern is straightforward to swap out for whatever your actual paid feature is.&lt;/p&gt;

&lt;p&gt;The implementation is a Stripe Checkout Session for subscribing, a Customer Portal session for managing or cancelling, and a webhook handler that updates the user's subscription status in the database. The backend returns 403 upgrade_required when a free-tier user hits a gated endpoint. The client handles that and redirects to the subscription screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest take on building with Claude
&lt;/h2&gt;

&lt;p&gt;It worked for this kind of project. The backend code was fast and I could review it confidently because I know the domain. Terraform was solid. The frontend worked but I was a less useful reviewer there — issues showed up at runtime rather than in code review, because I'm not strong enough on React to spot them from reading.&lt;/p&gt;

&lt;p&gt;What it doesn't replace is understanding. There were decisions throughout that I had to make — what to use, why, how the pieces fit together. Claude implemented those decisions. When something was wrong I had to notice and describe what was wrong. That requires knowing enough to have an opinion.&lt;/p&gt;

&lt;p&gt;If you're hoping to build something you don't understand by delegating to an AI, this workflow won't save you. If you know the domain well enough to review what comes back and catch what's off, the speed gain is real.&lt;/p&gt;

&lt;p&gt;MiniStack is a commercial template — US$197, one purchase, unlimited projects. You clone it, run node scripts/setup.js, answer three questions, and it renames everything across the monorepo. Then you wire up your credentials and push.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://gormez.gumroad.com/l/ministack" rel="noopener noreferrer"&gt;https://gormez.gumroad.com/l/ministack&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Also in this series: the auth architecture in detail (&lt;a href="https://dev.to/dgit/jwt-auth-across-web-and-mobile-in-aspnet-core-what-i-actually-built-and-why-10kd"&gt;https://dev.to/dgit/jwt-auth-across-web-and-mobile-in-aspnet-core-what-i-actually-built-and-why-10kd&lt;/a&gt;) — Google Sign-In: four bugs, four fixes — Terraform without the portal — Stripe subscription gating in ASP.NET Core (To be published soon)&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
