17 March 20266 min read

Bearer JWT vs cookie sessions: why we build token-only

A practitioner's case for Bearer JWT over cookie sessions: token-only auth makes every app native-ready by default, but you have to get storage, expiry, and refresh right.

The short answer on Bearer JWT vs cookie sessions: a cookie session keeps state on the server and rides on the browser automatically, which is convenient for a single website but breaks the moment you add a mobile app or a second domain. A Bearer JWT is a self-contained, signed token your client stores and attaches to each request as an Authorization: Bearer <token> header. We build token-only because the same auth works identically for a web app, an Android app, an iOS app, and a third-party integration. No second login system, no cookie scoping headaches. The trade-off is that you own storage, expiry, and refresh yourself, and you have to do it carefully.

What each approach actually does

With cookie sessions, the server creates a session record (in a database, Redis, or signed cookie) at login and hands the browser a session ID. The browser stores it and sends it back on every request to that domain on its own. The server looks up the session, finds the user, and proceeds. Logout is a server-side delete. This is the model PHP, Rails, and Django shipped with for two decades, and for a plain website it is genuinely hard to beat.

With Bearer JWT, the server signs a token at login that contains the claims it needs, such as user ID, which products the user can access, and an expiry timestamp, then returns it in the response body. There is no server-side session record to look up. Every later request carries the token in the Authorization header, and the server verifies the signature and reads the claims directly. Nothing is stored on the server between requests. The client is responsible for holding the token and sending it.

That single architectural difference, state on the server versus state in a signed token the client holds, is what drives every trade-off below.

Why token-only makes everything native-app-ready by default

This is the reason we standardised on it. Cookies are a browser concept. They depend on the same-origin model, the SameSite attribute, secure flags, and CORS rules for credentials. A native Android or iOS app has no cookie jar in the browser sense, so the moment an Indian SMB asks "can we also have a mobile app for our field staff," a cookie-session backend forces you to bolt on a token system anyway, usually a worse, half-built one.

If the backend was token-only from day one, the mobile app does exactly what the web app does: log in, get a token, store it in the OS keychain or secure storage, attach it to requests. No new auth layer. The same API serves your React web app, your Flutter or React Native app, and a partner's server-to-server integration with one consistent contract. For a studio shipping products across web and mobile, that consistency is worth a lot of avoided rework.

It also fits how modern apps deploy. When your API lives on a different domain from your frontend, say a worker on one host and the web app on another, Bearer tokens sidestep the cross-site cookie restrictions that browsers keep tightening every year. There is no SameSite=None tightrope to walk, no third-party cookie deprecation looming over your login flow. The token goes in a header you control, full stop.

The honest case for cookies

Token-only is not free, so be fair about where cookies win. If you are building one website with a server-rendered frontend and no plan for a mobile app or external API, cookie sessions are simpler and arguably safer out of the box. An HttpOnly cookie cannot be read by JavaScript, which means a cross-site scripting bug cannot directly steal it. Server-side sessions also give you instant, reliable logout and revocation: delete the record and the session is dead everywhere, immediately. With JWTs, a token stays valid until it expires unless you build extra machinery to revoke it.

So the real decision is about reach, not religion. One website, server-rendered, no apps? Cookies are fine. Anything multi-client, web plus mobile, or an API others consume, and token-only pays for itself fast.

Security: storage, expiry, refresh

The most common objection to JWTs is "where do you store the token in the browser." This is the part teams get wrong, so be deliberate.

Storage

On a native app, store the token in the platform secure store, the iOS Keychain or Android Keystore-backed storage. That part is straightforward. On the web it is genuinely a trade-off, and anyone who tells you there is a perfect answer is selling something.

  • localStorage is the easy default and survives reloads, but it is readable by any JavaScript on the page, so a single XSS bug exposes the token.
  • In-memory (a JS variable) is the safest against XSS theft because nothing persists, but the user is logged out on every refresh unless you pair it with a refresh mechanism.
  • The pragmatic web pattern is a short-lived access token in memory plus a refresh token in an HttpOnly cookie. Yes, that mixes in a cookie, but only for the refresh endpoint, and the cookie is never readable by JavaScript. Your API stays Bearer-only; the cookie just does the silent re-issue.

Whatever you pick, the non-negotiable is a strict Content Security Policy and disciplined input handling, because XSS is the threat model for browser token storage. If your site is full of XSS holes, no storage choice saves you.

Expiry

Keep access tokens short-lived. Minutes, not days. A short expiry limits the blast radius of a leaked token: even if someone grabs it, it is dead soon. Long-lived access tokens are the single biggest self-inflicted JWT wound, because you have given up the easy revocation that cookies gave you and then made the token live forever anyway.

Refresh

Pair the short access token with a longer-lived refresh token. When the access token expires, the client quietly exchanges the refresh token for a new access token without making the user log in again. Rotate refresh tokens on each use, issuing a new one and invalidating the old, so a stolen refresh token is detectable: if the old one gets used after rotation, you know it was replayed and can kill the whole chain. This is also where you reintroduce real revocation: keep refresh tokens server-side or in a fast store so logout actually invalidates them, even though the access tokens themselves stay stateless.

Verify the signature on every request and check the expiry. Use a strong asymmetric or properly-managed symmetric key, and never trust an alg: none token. Put authorization data in the claims so each request is self-describing. In a multi-product setup, that means the token can carry which products the user is entitled to, and the API gates on that without a database round-trip per call.

How we apply this at BotBrained

Across the products we build and run, including Pariq CRM, auth is Bearer JWT, never cookies, and the rule is non-negotiable in our architecture. Every app is treated as native-app-ready by default, so the web build and a future mobile build share one auth contract and one API. Tokens are verified at the edge before any business logic runs, access tokens are short-lived, and refresh handles the silent re-issue so users are not re-typing passwords. It is more upfront work than dropping in a session cookie, but it means the answer to "can we add an app" is always yes, with no auth rewrite. That is the practical payoff of going token-only.

If you are weighing this for your own product and want a second opinion on the storage-and-refresh details for your specific stack, the BotBrained engineering team is happy to talk it through.