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.
I didn't. Here's what I built instead, why, and the parts that tripped me up.
This is part of a series on MiniStack — a full-stack mobile boilerplate built on Expo + ASP.NET Core. The # covers the full stack and how the whole project came together.
Why not Auth0 or Clerk
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.
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.
What I built ended up being fairly standard — a split-token pattern with some decisions around per-device sessions that are worth explaining.
The token model
Two tokens. Short-lived access tokens, long-lived refresh tokens.
Access tokens 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.
Refresh tokens are different. They're persisted — one row per device in a UserRefreshTokens 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.
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.
Where the tokens live on each platform
On mobile (Expo): the refresh token goes into expo-secure-store, 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 /api/auth/refresh, and gets a new access token back.
On web (Next.js): the browser never sees the refresh token directly. There's a small backend-for-frontend layer — three Next.js API routes:
-
POST /api/set-refresh-token— stores the refresh token in an httpOnly cookie after login -
POST /api/refresh— reads the cookie, calls the ASP.NET Core refresh endpoint, updates the cookie -
POST /api/clear-refresh-token— deletes the cookie on logout
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.
Google and Apple Sign-In
The mental model that unlocked this for me: the backend doesn't do an OAuth redirect flow. It just validates a JWT.
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 id_token from Google, an identityToken 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.
For Google: GoogleJsonWebSignature.ValidateAsync from the Google.Apis.Auth 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 https://appleid.apple.com/auth/keys and validate the JWT manually, checking the bundle ID as the audience.
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.
One Apple-specific thing worth knowing: Apple only gives you the user's name on the very first sign-in. After that, you get a stable user ID (sub) but no name. Save the name on that first call or you'll never get it.
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.
Email verification and password reset
Registration issues a refresh token immediately so the user can start using the app, but sets EmailVerified = false. A 24-hour verification link goes out by email. OAuth registrations skip this — the provider already verified the address.
Password reset follows a pattern you've probably seen: POST /api/auth/forgot-password 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.
Rate limiting
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.
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 Program.cs — skip AddRateLimiter and UseRateLimiter when the environment is Testing. Obvious once you've hit it, not obvious before.
A few smaller decisions
ClockSkew = TimeSpan.Zero — 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.
CORS via config, not code — Origins are configured via appsettings (Cors:AllowedOrigins) rather than hardcoded. An empty array falls back to AllowAnyOrigin for local development, so you're not fighting CORS during development but you're forced to be explicit in production.
Security headers — X-Content-Type-Options, HSTS, 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.
Configuration
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.
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.
Is it worth building yourself?
For me, yes. I own the implementation, I understand every part of it, and there's no external dependency on my authentication path.
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.
The code for all of this is in MiniStack. The auth architecture is documented in full in AUTH.md in the repo, including flow diagrams for every auth path and a full configuration reference.
Questions? Open a discussion.

Top comments (0)