Published on

The Frontend Holds No JWT: Reasoning Backward from "Where to Store the Token" to a Cookie Auth + RBAC Design Space

Authors
  • avatar
    Name
    Jack Qin
    Twitter

"Where do you store the JWT" is the first fork in nearly every frontend auth scheme, and the overwhelming majority of answers — localStorage, sessionStorage, in-memory — share one implicit premise: the frontend holds the token. Once you accept that premise, "prevent token leakage" is permanently written onto the frontend's responsibility list, and the frontend's biggest attack surface is XSS, so you spend the rest of your life wrestling with "don't let any line of JS read the token out."

But that premise need not be accepted. If you swap the token for an HttpOnly Cookie, the frontend's JS simply can't read it — the entire item "token leakage" is struck off the frontend's responsibility list. The frontend has no token, so there's nothing to leak. This isn't "harder to leak" — it's that, mechanically, there is nothing to leak.

This post uses an environmental-monitoring platform as a worked example, but the point isn't to describe "we used ASP.NET Core Identity + Cookie"; it's to unpack this auth scheme's decision space: why JWT was abandoned, why CSRF therefore becomes an unavoidable cost, how permissions are unioned from "direct grants + group inheritance," why a 5-minute cache makes invalidation harder than caching itself, and why frontend and backend cache times must be aligned. Each comes back to the same question — did this choice eliminate the risk, or just move it somewhere else.


What This Permission System Is Fundamentally Expressing

The monitoring platform serves multiple mining sites, and users need fine-grained access control along two dimensions, "module" and "site": a given user can see dust readings but not change alert thresholds, can access Site A but not Site B. This is naturally RBAC.

A few constraints directly shape the scheme:

  • Frontend and backend share a root domain (*.<platform-domain>), so there's no cross-origin Cookie hassle;
  • The user base is small, so a one-off cutover + email invitations to reset passwords is acceptable;
  • The long-term goal is to fully reclaim backend capabilities, no longer relying on the early BaaS's auth;
  • Security first: risks like XSS token theft should be eliminated mechanically, not by "just don't write XSS bugs."

Auth is based on Identity + HttpOnly Cookie; permissions are computed on the backend, and the frontend only receives the "effective permissions":

sequenceDiagram
    participant B as Browser
    participant S as ASP.NET Core Identity
    B->>S: POST /api/v1/auth/login {email, password}
    S->>S: SignInManager validates credentials
    S-->>B: Set-Cookie session cookie (HttpOnly; Secure; SameSite=Lax)
    B->>S: GET /api/v1/users/me
    S->>S: CurrentUserMiddleware reads cookie → resolves permissions → populates ICurrentUser
    S-->>B: CurrentUserDto (role + effective permissions)

The decision record is clear: choose Cookie + Identity over JWT Bearer, for four reasons:

  1. The long-term goal is to fully reclaim the backend, and a self-built Identity is more controllable than continuing with third-party tokens;
  2. Frontend and backend share a root domain, so Cookies have no cross-origin issue;
  3. An HttpOnly Cookie mechanically prevents XSS token theft — the frontend JS simply can't read it, which a JWT in localStorage cannot achieve;
  4. The user base is small enough for a direct cutover (email invitations to reset passwords).

Of the four, the third is the real axis; the other three are "no reason to object." It's worth putting it side by side with JWT to see clearly: a JWT in localStorage can be stolen by any XSS via localStorage.getItem; an HttpOnly Cookie is invisible to JS at the browser level, so XSS can't steal it. The difference between the two isn't "which is a bit more secure" — it's whether the risk is eliminated or merely mitigated. Under the JWT scheme you're forever mitigating (by not writing XSS); under the Cookie scheme that specific risk is structurally removed.

Core idea in one line: the frontend never holds a JWT; the auth state is entirely Cookie-driven. The Cookie config splits into dev and production:

// Development
Cookie.Name = "<auth>";
Cookie.SecurePolicy = CookieSecurePolicy.None;

// Production
Cookie.Name = "__Host-<auth>";  // forces HTTPS + no cross-subdomain sharing
Cookie.SecurePolicy = CookieSecurePolicy.Always;

// Common
Cookie.HttpOnly = true;
Cookie.SameSite = SameSiteMode.Lax;       // allows OAuth redirects
ExpireTimeSpan = TimeSpan.FromDays(14);
SlidingExpiration = true;
Events.OnValidatePrincipal = SecurityStampValidator.ValidatePrincipalAsync;  // invalidates immediately on password change

Using the __Host- prefix in production is a detail but a crucial one — it forces HTTPS and disallows subdomain sharing at the protocol level, not by application code's good intentions. SameSite=Lax lets OAuth redirects carry the Cookie while blocking most CSRF. OnValidatePrincipal wired to SecurityStampValidator means that after a user changes their password, old sessions are immediately invalidated.


Here we have to be honest — the Cookie scheme isn't a free lunch. Using Cookies means you must add CSRF protection, because the browser automatically attaches Cookies, and an attacker can induce a user's browser to send a malicious request with the Cookie. This is the dual cost of the "automatic attachment" convenience of Cookies: automatic attachment frees the frontend from managing the token, and also makes the browser carry it on the attacker's behalf.

The mechanism is double-token:

  • On login, the backend issues a non-HttpOnly XSRF-TOKEN Cookie (which frontend JS can read);
  • The frontend reads it and sends it back in the X-XSRF-TOKEN header of all mutating requests (POST/PUT/PATCH/DELETE);
  • Auth endpoints (/login, /forgot-password, etc.) are explicitly exempted — they have no session to be CSRF'd in the first place.
function getCsrfToken(): string | null {
  const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/)
  return match ? decodeURIComponent(match[1]) : null
}
headers.set('X-XSRF-TOKEN', csrfToken)

Why the double-token blocks CSRF: an attacker can make the browser automatically attach the HttpOnly session Cookie, but can't read the XSRF-TOKEN (cross-site script can't get the target domain's Cookie value), so it can't construct the correct X-XSRF-TOKEN header. The session Cookie comes automatically, but the CSRF token must be actively read to be sent — that asymmetry is the line of defense.

Lay the costs of the two schemes side by side: the JWT scheme skips CSRF (the token isn't auto-attached) but must manage XSS theft; the Cookie scheme skips frontend token management (XSS theft prevented) but must manage CSRF. There's no free scheme, only a choice of which side to move the complexity to. The team chose the CSRF side, the reasoning being that it has a mature, clean double-token paradigm, whereas JWT's XSS theft has no equally clean solution. That's the "why this and not the other" — Cookie isn't perfect, its cost is just more controllable.


Permissions = Direct Grants ∪ All Group Grants

The permission model has two dimensions (module, site) × two sources (user-direct, group-inherited):

flowchart TD
    User[User]
    User --> DM[Direct module permissions<br/>user_module_permissions]
    User --> DS[Direct site permissions<br/>user_site_permissions]
    User --> GM[Group membership<br/>user_group_members]
    GM --> GMP[Group module permissions<br/>group_module_permissions]
    GM --> GSP[Group site permissions<br/>group_site_permissions]

Effective permissions = the union of direct + all group permissions. If any source grants it, has_access is true. The admin role bypasses all checks. Resolution flow:

  1. role = 'admin' → access to all modules, short-circuit return;
  2. Load direct module/site permissions (those with has_access = true);
  3. Load group memberships (active groups only);
  4. For each active group, load the group's module/site permissions;
  5. Union all sources (OR);
  6. Cache in IMemoryCache, TTL 5 minutes, key permissions:{userId};
  7. On a permission/group change, proactively invalidate via PermissionCacheInvalidator;
  8. The merged UserContext (role + active flag + permissions) is cached under user-context:{userId}, populated once per request by middleware.

One simplification decision worth remembering: the early model had two booleans, can_view + can_edit, later merged into a single has_access; the AppModule enum was also replaced by constant strings validated by AppModules.IsValid(). The motivation for the simplification: the two booleans didn't add enough distinction in the actual permission semantics, and instead manufactured a combinatorial explosion — the Cartesian product of two booleans across two dimensions, where the vast majority of combinations are meaningless in the business. This is an often-overlooked judgment: a dimension is worth keeping only if it truly corresponds to distinct business behaviors; otherwise it's just manufacturing a meaningless state space.


Why Cache Invalidation Is Harder Than Caching Itself

PermissionResolver.ComputePermissions() runs multiple DB queries per resolution (direct permissions, group membership, group permissions). Recomputing on every request is too expensive, so we cache. But the real difficulty of caching is never "storing" — it's "when to invalidate." Storing only requires putting the result in a dictionary; invalidation requires that, when a change happens in some corner of the system, you accurately know which cache entries are affected.

  • permissions:{userId}EffectivePermissionsDto, TTL 5 minutes;
  • user-context:{userId}UserContext record, TTL 5 minutes;
  • Proactive invalidation: InvalidateUser(userId) (on direct permission/role change), InvalidateGroupMembers(groupId) (on group permission/membership change, evicting the cache of all members of that group).

Why a 5-minute TTL alone isn't enough: an admin who just changed someone's permissions can't let them keep using the old permissions for 5 more minutes — this is security semantics, not a performance issue. So when group membership/group permissions change, you must proactively evict the cache of all affected members. There's an easy-to-miss point hiding here: a group permission change affects not one user but the entire group's members. InvalidateGroupMembers must do a fan-out invalidation; miss it and you get "changed the group's permissions, but some members are still on the old ones." The difficulty of cache invalidation is fundamentally that "the scope of a change's impact" and "the cache's key structure" aren't one-to-one — one group change maps to many user keys, and this many-to-one relationship is a hotbed of bugs.


Frontend–Backend Cache Alignment: An Overlooked Consistency Detail

The backend permission cache is 5 minutes, and in the frontend the staleTime of /users/me in TanStack Query is deliberately set to 5 minutes to align:

export const AUTH_ME_STALE_TIME = 5 * 60 * 1000 // aligned with the backend permission cache

Why this alignment matters becomes clear from the consequences of erring in either direction: if the frontend cache is shorter than the backend's, you get a pointless round trip where "the frontend asks, and the backend returns its own old cache" — a wasted request fetching the same stale data; if the frontend cache is longer than the backend's, you get a window where "the backend permissions are updated but the frontend is still on the old ones." Two layers of cache each have their own TTL, and only by making their invalidation cadences consistent does the whole chain's "permission freshness" have a single, reasoned-about upper bound. When one piece of data is cached separately at multiple layers, those TTLs aren't independent parameters — they're a set of constraints that must be designed together.


Implementation Details

Backend: the ICurrentUser Abstraction

Permission checks happen in the endpoint via a BuildingBlocks interface; modules don't touch Identity directly:

public interface ICurrentUser
{
    Guid UserId { get; }
    string Role { get; }                              // "admin" or "user"
    bool IsAdmin { get; }
    IReadOnlySet<string> ModulePermissions { get; }   // set of module keys
    IReadOnlySet<Guid> SitePermissions { get; }       // set of site IDs

    bool CanAccess(string moduleKey);   // admin allowed directly, otherwise check the set
    bool CanAccessSite(Guid siteId);    // same
}

Backend: Persisting Data Protection

Cookie auth relies on Data Protection keys to protect the Cookie. These keys are persisted to PostgreSQL, not kept in memory:

services.AddDataProtection()
    .SetApplicationName("<platform>")
    .PersistKeysToDbContext<IdentityDbContext>();

The reason is again "where the state lives decides everything": if the keys live only in memory, every container restart forces all users to log in again, and multiple instances can't share them. Persist the keys to the DB, and neither restart nor horizontal scaling drops logins.

Frontend: me() Turns 401 into null

When querying the current user, a 401 doesn't throw but returns null, because "not logged in" is an expected state, not an error:

me: async (options?) => {
  try {
    return await apiClient.get<CurrentUserDto>("/api/v1/users/me", { signal: options?.signal });
  } catch (err) {
    if (isApiError(err) && err.code === "unauthorized") return null;
    throw err;
  }
},

Frontend: a Single Source of Permission Truth

The frontend recognizes only one permission source, GET /api/v1/users/me — it returns CurrentUserDto carrying EffectivePermissionsDto (the effective permissions, with the union already computed on the backend). The frontend doesn't recompute inheritance; it only consumes the result. This avoids the classic problem of "auth context" and "permission service" being two inconsistent sources — a single fact may have only one authoritative source; recomputing inheritance on the frontend creates a second truth, and two truths will eventually diverge.

The permission utility functions are stateless pure functions (no React dependency), easy to reuse and test:

isAdmin(user): boolean
canAccessModule(user, module, requireEdit?): boolean   // admin → true
canAccessSite(user, siteId, requireEdit?): boolean

Frontend: Route-Level and Component-Level Double Protection

// Route-level: redirect to NoPermissionPage if unauthorized
<ProtectedRoute requiredModule="dust_level">{children}</ProtectedRoute>
<ProtectedRoute adminOnly>{children}</ProtectedRoute>

// Component-level: hide directly if unauthorized (no redirect), for conditional UI
<PermissionGate module="email_schedules" requireEdit fallback={null}>
  <EditButton />
</PermissionGate>

There's one understanding that must be nailed down here: the frontend's permission checks are a UX layer (hide buttons, redirect early), not a security boundary. The real access control is on the backend endpoint's CanAccess / CanAccessSite. The frontend checks only keep the user from clicking actions destined for 403 — they guard against "mistakes," not "malicious bypass." Treating frontend checks as a security boundary is a classic source of privilege-escalation bugs: tweak the frontend and you can send the request, and if the backend doesn't validate independently, you're through.

Global 401 Handling and the OIDC Option

Any apiClient call that gets a 401 triggers a global callback: clear all TanStack Query caches + redirect to login.

setOnUnauthorized(() => {
  queryClient.clear()
  window.location.href = '/login'
})

Enterprise identity providers (OIDC) for single sign-on are supported, but only when the corresponding tenant ID is configured. It requires an existing account matching the email; after a successful OAuth callback, the issued cookie is the same HttpOnly Cookie, going through the same session mechanism as password login. There's no open self-registration — all accounts are created by admin invitation. Note that OIDC reusing the same Cookie session means all the mechanisms above (Cookie/CSRF/permissions) apply to OIDC-logged-in users identically — there's no need to design a separate auth-state scheme for SSO.


What this scheme buys is clear: XSS token theft is mechanically eliminated (the frontend has no token), permission computation is centralized on the backend to rule out dual-source inconsistency, and the cache has proactive invalidation aligned across frontend and backend. But its premise is equally clear — frontend and backend share a root domain. Leave that premise and the scales tip toward JWT:

  • A genuinely cross-origin SPA + API (different root domains): Cookies need SameSite=None; Secure and complex CORS config, whereas JWT goes more smoothly;
  • Multiple clients (mobile app, third-party integrations) sharing one API: Cookies don't suit non-browser clients, and JWT/OAuth tokens are more natural;
  • Stateless horizontal scaling taken to the extreme, with no desire for any server-side session key sharing.

So the conclusion isn't "Cookie beats JWT," but "in the most common scenario — browser + same root domain — HttpOnly Cookie's security advantage (XSS-theft prevention) usually outweighs JWT's convenience." Change the scenario and the conclusion flips.

The transferable layer: the essential choice in an auth scheme is deciding who bears the responsibility of "preventing token leakage." Have the frontend hold the token, and that responsibility hangs forever on the frontend's XSS attack surface; have an HttpOnly Cookie hold it, and that responsibility, along with its entire attack surface, disappears from the frontend — the cost being that you now own CSRF. No scheme can shed both — you can only choose which class of risk to live with. Faced with any "where to store the credential" question, ask first: does this choice eliminate the risk, or just move it somewhere I have a better handle on?