Written by Huifer, solo developer and maintainer of TanStack Ship. I have shipped authentication on nine production SaaS applications since 2023 — Better Auth on Cloudflare Workers with sessions in KV, OAuth integrations with GitHub and Google, MFA via TOTP and WebAuthn, and authorization models ranging from simple RBAC to ReBAC for a B2B platform with workspace inheritance. This guide consolidates the patterns I have come back to: how to authenticate, how to authorize, how the edge runtime changes the calculus, the failure modes that page me at 2am, and the production checklist I run before shipping a new auth surface. TanStack Ship is my paid product; I name that bias up front.
Verified sources: OWASP Password Storage Cheat Sheet · RFC 6749 — OAuth 2.0 Authorization Framework · OpenID Connect Core 1.0 · Cloudflare Workers KV · Cloudflare Durable Objects · WebAuthn Guide — MDN · Better Auth documentation · TanStack Ship auth reference repo
Last updated: 2026-07-15 · Changelog
TL;DR: Authentication answers "who is this request?"; authorization answers "what are they allowed to do?". On the edge runtime both move into a hot path — every authenticated request reads a session, so the session store and the authorization check together decide your p99 latency. The patterns I ship in 2026: Argon2id for password hashing with OWASP-recommended parameters, server-side sessions in KV with versioned session-ids to defeat the multi-device race, OAuth 2.0 / OIDC for third-party login, TOTP plus WebAuthn for MFA, RBAC for simple apps, ABAC for attribute-driven policies, and ReBAC for B2B workspaces where membership inherits permissions. The failure modes that bite hardest are session races across devices, CSRF on cross-origin POST endpoints, and JWT-in-localStorage XSS exfiltration — each gets a concrete fix in this article.
Authentication: The Identity Layer
Authentication is the part of your SaaS where mistakes are catastrophic and quiet — a session race might not surface for months, but when it does, you are answering to your largest customers about why they had to log in twice. The patterns below are the ones I have shipped, broken, and re-shipped.
Password hashing with Argon2id
The password hashing choice has been stable since 2023: Argon2id, never bcrypt, never scrypt, never anything you wrote yourself. The OWASP Password Storage Cheat Sheet publishes the parameters I use across all nine deployments: memory cost 19 MiB, time cost 2, parallelism 1. On Cloudflare Workers the Argon2 bindings come through better-auth/crypto and run as a WebAssembly module — p95 hash time is around 90 ms with the conservative parameters, which is well under the Workers CPU budget for a login handler but too expensive to run on every request.
Server-side sessions on the edge runtime
Stateless authentication (JWT in localStorage) is fashionable in tutorials and a security incident waiting to happen. The pattern I ship is server-side sessions: a random session-id in an HTTP-only cookie, the session payload in a session store, and a versioned session-id scheme that defeats the multi-device race documented in the Better Auth postmortem. On Cloudflare Workers the session store is KV for read-heavy access: a session-id-to-payload mapping, TTL-driven expiry, with active invalidation on logout and on the user's session-version counter. Reads land at p99 ~5 ms globally, which is fast enough that you do not need to cache sessions in-process.
The version scheme that solves the multi-device race is short: every session-id embeds a monotonic counter ({userId}.{version}) and every session mutation increments the user's version counter. A session whose embedded version is older than the user's current version is treated as superseded and invalidated on the next request. The postmortem article has the reproduction recipe; the production number is a 10-second median propagation delay globally, down from the 60-second worst case with TTL-only expiry.
OAuth 2.0 and OpenID Connect for third-party login
For "Sign in with Google / GitHub / Microsoft" you implement OAuth 2.0 with OpenID Connect layered on top. The pieces you actually need: the authorization endpoint, the token endpoint, the JWKS endpoint for verifying ID-token signatures, and the userinfo endpoint as a fallback when the ID-token claims are insufficient. The scopes I request are openid profile email for a basic identity assertion and the provider-specific ones when I need additional data — read:user on GitHub, https://www.googleapis.com/auth/userinfo.email on Google.
The two failure modes that surface in production are state-mismatch on the callback (the OAuth state parameter does not match the one stored at the start) and ID-token signature verification skipped because the developer trusted the kid claim. The fix is mechanical: store the OAuth state server-side in KV with a 10-minute TTL, compare on callback, and use a JWKS library — not a hand-rolled verifier — to check the ID-token signature. The RFC 6749 section on the authorization code grant is the reference; do not roll your own.
MFA, TOTP, and WebAuthn
Email-password plus a social-login button is the 2026 baseline. For anything that touches payment data, customer data, or admin actions, you also ship MFA. The two mechanisms that work in practice are TOTP (RFC 6238, Google Authenticator and 1Password are the canonical clients) and WebAuthn platform authenticators (MDN WebAuthn guide). The order I prefer is TOTP for the first MFA factor because it works on every device, then WebAuthn as an option for users on a modern browser because it is phishing-resistant and faster to use.
Authorization: Who Is Allowed to Do What
Authentication establishes identity. Authorization is the policy layer that decides what the identity can do. The three models I ship in 2026 are RBAC, ABAC, and ReBAC — and the decision between them is not a feature preference, it is a function of the shape of the data.
RBAC for simple cases
Role-Based Access Control maps users to roles and roles to permissions. A user has one role, a role has a fixed set of permissions, and the authorization check is a permission lookup. For a single-tenant SaaS with internal users and admin/editor/viewer tiers, this is the right model and it is the one I default to. The implementation is a role column on the user record and a server function that loads the role's permissions from a config file at boot time.
// src/lib/auth/rbac.ts
import type { Role, Permission } from "../types";
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
admin: ["billing:read", "billing:write", "users:read", "users:write",
"workspace:delete", "content:publish"],
editor: ["content:publish", "users:read"],
viewer: ["users:read"],
};
export function can(role: Role, permission: Permission): boolean {
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false;
}
The check at the edge of every server function is if (!can(ctx.user.role, "billing:write")) throw new ForbiddenError(). RBAC is the right model when the permission set is small (under ~30 distinct permissions), when users have one role at a time, and when the roles do not change often. If your permission set is in the hundreds or the role assignment is dynamic per-resource, you have outgrown RBAC.
ABAC for attribute-driven policies
Attribute-Based Access Control evaluates a policy against a tuple of attributes — subject, resource, action, environment. The canonical example is "the user can edit this document if they are the document owner and the document is in draft state." ABAC is what you reach for when RBAC's role-based abstraction is too coarse. The implementation pattern I ship is a policy file with structured rules and a single authorize() function that evaluates the rule set against the request context.
// src/lib/auth/abac.ts
import type { Policy, Subject, Resource, Environment } from "../types";
const POLICIES: Policy[] = [
{
id: "doc.edit.owner-draft",
effect: "allow",
actions: ["doc:edit"],
condition: (s: Subject, r: Resource) =>
s.id === r.ownerId && r.status === "draft",
},
{
id: "doc.publish.editor",
effect: "allow",
actions: ["doc:publish"],
condition: (s: Subject) => s.role === "editor" || s.role === "admin",
},
];
export function authorize(
subject: Subject,
action: string,
resource: Resource,
env: Environment
): boolean {
return POLICIES.some(p =>
p.actions.includes(action) &&
p.condition(subject, resource, env) &&
p.effect === "allow"
);
}
ABAC is the right model when the policy is a function of attributes you cannot enumerate in advance. I have not seen this become a performance bottleneck until the policy file exceeds ~500 rules, at which point you reach for a rule engine.
ReBAC for B2B workspaces
Relationship-Based Access Control evaluates permissions as a function of relationships in a graph. The canonical example is Google Docs: Alice can edit because Alice is an Editor on the document, the document is in Workspace W, and Alice is a Member of Workspace W with the Editor role inherited. For B2B SaaS with workspaces, projects, and inherited permissions, ReBAC is the model that does not collapse under its own weight.
The implementation pattern is a relationship graph in the database — (subject, relation, object) tuples — and a traversal that checks whether a path exists from the user to the resource with the right relation. On Cloudflare Workers I store the relationship tuples in Durable Objects with one DO per workspace as the coordination point; the lookup runs in single-digit milliseconds. This pattern is described in the multi-tenant architecture guide — ReBAC and multi-tenant scoping are the same problem at different layers.
Edge Runtime Specifics: Why the Auth Story Changes on Workers
The edge runtime is not "just another deployment target" — it changes the cost model, the consistency model, and the failure surface of your auth layer.
KV for session cache, D1 for the source of truth
The session store has two halves. The hot path — every authenticated request reads here — is KV: a session-id-to-payload mapping with TTL-driven expiry. The cold path — login, logout, password reset, MFA enrollment — is D1: durable user records, audit logs, and the canonical session-version counter. Reads from KV land at p99 ~5 ms globally; reads from D1 land at p99 ~30 ms. The discipline is to never read from D1 in the request hot path; the session payload in KV carries everything the request needs.
Durable Objects for rate limiting and account lockout
Login endpoints are the canonical brute-force target, and naive IP-based rate limiting is bypassed with residential proxies. The pattern I ship is a Durable Object per account as the lockout coordinator: failed login attempts increment a counter on the DO, five failures in 10 minutes lock the account for 15 minutes, and the DO's single-writer consistency makes the counter atomic across regions. The same pattern covers password-reset throttling and OAuth state validation.
Cross-region consistency and the 10-second window
KV is eventually consistent with a global propagation window of up to 60 seconds in the worst case. Session reads tolerate this because the session payload itself is immutable for the session's lifetime. What does not tolerate eventual consistency is the session-version counter for multi-device invalidation. The median latency on KV propagation globally is ~10 seconds in my instrumentation — that is the worst-case time between a user logging in on device B and device A recognizing the session has been superseded.
Failure Modes That Page You at 2am
The four failure modes below are not theoretical. Each one is a real incident I debugged, named here so you can pattern-match when you hit them.
Multi-device session races
The symptom: a user logs in on device B, then refreshes device A, and device A continues to show the older session state for up to 60 seconds. The cause: TTL-based session expiry with no active invalidation when a newer session is created. The fix: the versioned session-id scheme described above, plus active invalidation on every session mutation. The reproduction recipe and the fix are documented in the Better Auth postmortem.
CSRF on cross-origin POST endpoints
The symptom: a logged-in user's browser submits a state-changing request to your API from a different origin, and your server processes it because the cookie is sent. The fix: SameSite=Lax on the session cookie as a baseline, SameSite=Strict for the strictest posture, and an explicit CSRF token check on any state-changing endpoint that accepts cross-origin requests. The rule of thumb is that anything reachable from fetch() with credentials: "include" needs a CSRF token.
JWT-in-localStorage XSS exfiltration
The symptom: a single XSS in any dependency reads the JWT out of localStorage and exfiltrates it to the attacker's server. The fix: HTTP-only cookies for session tokens, never localStorage, never sessionStorage, never any client-side storage that JavaScript can reach. If you must use bearer tokens for an API, give them a 5-minute expiry and require refresh through a server-side endpoint.
OAuth state mismatch on the callback
The symptom: a user clicks "Sign in with Google," is redirected to the consent screen, approves, returns to the callback, and is shown a "session expired, please try again" error. The fix: store the OAuth state in KV with a 10-minute TTL and validate it server-side on the callback. The state mismatch is recoverable; the more dangerous variant — skipped ID-token signature verification — is not recoverable at all.
Production Checklist
The five-step checklist I run before shipping a new auth surface on a TanStack Ship deployment:
- Password hashing: Argon2id with OWASP-recommended parameters (memory 19 MiB, time 2, parallelism 1). Bcrypt is rejected. SHA-anything is rejected.
- Session storage: server-side sessions in KV with a versioned session-id scheme, HTTP-only / Secure / SameSite=Lax cookies, TTL 24 hours with active refresh on each validated request.
- Authorization at the edge: every server function checks authorization before reading or writing. The check is the first line, not a middleware afterthought.
- OAuth state and signature verification: OAuth
statestored server-side with a 10-minute TTL; ID-token signatures verified with a JWKS library. - Rate limiting and lockout: Durable Object per account for failed-login tracking, lockout after 5 failures in 10 minutes, password-reset throttling, OAuth state validation.
If any of those five is missing, the deployment is not ready. The patterns in this article are the same patterns TanStack Ship ships by default — the features page lists what is wired in for new projects.
Closing
Authentication and authorization are not a single decision — they are a stack of decisions, each with its own tradeoffs and its own failure modes. The patterns I default to in 2026 are Argon2id password hashing, server-side sessions in KV with versioned session-ids, OAuth 2.0 / OIDC for third-party login, TOTP plus WebAuthn for MFA, RBAC for simple cases, ABAC for attribute-driven policies, and ReBAC for B2B workspaces.
For the broader context on multi-tenant scoping, see the multi-tenant architecture guide. For the edge runtime that hosts all of this, the Cloudflare Workers 2026 guide covers the execution model. If you are choosing an auth library, the Better Auth comparison has the side-by-side. TanStack Ship ships every pattern in this article by default; the features page and pricing page list what is included.