SevyDevy

Guide · Frontend

Cookies vs Sessions vs Tokens in the Browser

26 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • In cookies vs sessions vs tokens, a cookie is only a carrier: a small value the browser stores and sends back. A session keeps your login on the server, while a token such as a JWT (JSON Web Token) carries signed proof itself.
  • For a browser app with its own backend, the safest default is a random session ID in a cookie. Mark it HttpOnly (scripts cannot read it), Secure (HTTPS only) and SameSite (held back on most cross-site requests). Then add a CSRF check, which stops other websites triggering requests that carry it.
  • A token kept in localStorage, the browser's long-lived key-value store, can be read by any script on your site. One cross-site scripting (XSS) bug, where an attacker's script runs in your page, can copy it.
  • If your app must call APIs that need OAuth tokens, keep those tokens on a backend for frontend (BFF), a small server you run. OAuth tokens let one app act for a user at another service. The browser then holds only a session cookie.
  • Beginners can stop after the localStorage section. Engineers get two traps we reproduced on Chromium 141 and Express 5.3.0: a logout call that leaves the cookie behind, and cookies without SameSite riding cross-site form POSTs. They also get RFC 10017's August 2026 rules for where browser apps keep OAuth tokens.

What goes wrong when a browser app keeps its login token in the wrong place?

If the proof of login sits where any script on the page can read it, one injected script can copy it and act as the user. In cookies vs sessions vs tokens, the real question is where that proof lives and who can reach it.

Picture a common setup. A small cooperative bank is rebuilding its net-banking site as a single-page app, a site that loads once and then redraws itself with JavaScript. The setup is made up, but the mistake is real.

The first build works like many tutorials. After login, the server returns a JSON Web Token (JWT), a signed string that says who you are. The app saves it in localStorage, valid for seven days, and attaches it to every request.

Before launch, a security review tries one test. The reviewer saves a payee nickname with a small script hidden inside it. The transfer page shows the nickname without escaping it, that is, without turning its special characters into plain text, so the script runs.

That script can read the token in one line and send it anywhere. Whoever holds the token can act as that customer until it expires, seven days later. Logging out does not help, because the server keeps no list of tokens it could cancel.

The rest of this guide works out where the login should live instead, using the bank's site as the running example.

Which words do you need before comparing cookies, sessions and tokens?

You need five words: cookie, session, token, XSS and CSRF. Each one gets a plain meaning and an example from the bank's site.

Cookie. A cookie is a small named value that a server asks the browser to store. The browser then sends it back on later requests to that server, without any code from you. Example: the bank sets a cookie that holds a random session ID.

Session. A session is the server's own record of a logged-in visit: who it is, when it started and when it ends. The browser holds only a random ID that points to that record.

Token. A token is a string that proves something by itself. A JWT holds facts such as the user ID and an expiry time, plus a signature that shows the server issued it.

XSS. Cross-site scripting (XSS) is when an attacker gets their own script to run inside your page. The payee nickname in the review was an XSS attack. The script runs with the same powers as your own code.

CSRF. Cross-site request forgery (CSRF) is when another website makes the user's browser send a request to your site. The browser may attach your cookies to it, so the request looks real.

Cookies vs sessions vs tokens: what is the difference?

A cookie is a place to carry a value, while sessions and tokens are two ways of proving who is logged in. You can carry a session ID in a cookie, and you can carry a token in a cookie too.

This is the part most beginners mix up. The question is not cookie or token. There are two separate choices: where the proof lives, on the server or inside the string, and where the browser keeps the string.

Picture a water park. At the tube counter sits a ledger with a line for every tube that is out right now. When you take a tube, the clerk writes its number in the ledger and hands you a numbered chit.

The chit is the session cookie. It means nothing by itself and only works while the ledger shows that tube as out. If the clerk strikes out the line, the chit is dead at once, even in your pocket. Deleting a server session ends a session cookie the same way.

Now picture the season pass. It shows your name, an expiry date and the park's hologram. Every slide attendant accepts it without asking the counter, because the hologram proves the pass itself. That pass is a token.

The pass is convenient, because nobody needs the ledger. But if you lose it, a stranger can ride the slides until the expiry date, and the attendants have no way to know.

Where the picture breaks: nobody checks a real token by eye. The server checks a cryptographic signature with its key, so a forged pass fails unless that key leaks.

The three ideas side by side, using the water park picture
CookieServer sessionToken (such as a JWT)
What it isA value the browser stores and sends backA record on the serverA signed string holding facts
Water park pictureThe tube chit in your pocketThe counter ledgerThe hologram season pass
Where the proof livesNowhere: it only carries a valueOn the serverInside the string
Cancel one login nowDepends on what it carriesYes, delete the recordHard, until it expires
Needs a server lookupDepends on what it carriesYes, on every requestNo, only a signature check
Main browser riskCSRF, if sent on cross-site requestsTheft of the IDTheft by XSS, if scripts can read it

How does session-based login work in the browser?

In session-based login, the server checks your password, creates a session record, and sends back a cookie holding only a random session ID. On each later request, the browser sends that cookie and the server looks up the record.

Follow the boxes from the login form to the balance page.

Session login on the bank's site

Notice that the customer's details never leave the server side. The browser holds one random ID, and every request is checked against the store.

Diagram connections:

  • Send ID and password to Check the password
  • Check the password to Create a session record (Password correct)
  • Create a session record to Set the session cookie
  • Set the session cookie to Ask for the balance (Later requests)
  • Ask for the balance to Look up the session
  • Send ID and password: Browser → Bank server
  • Check the password: Bank server → User database
  • Create a session record: Bank server → Session store
  • Set the session cookie: Bank server → Browser
  • Ask for the balance: Browser → Bank server, cookie attached
  • Look up the session: Bank server → Session store

The session ID must be impossible to guess. The OWASP Session Management Cheat Sheet asks for at least 64 bits of entropy, a measure of randomness. OWASP is a non-profit that publishes security guidance.

Here is the team's session helper in TypeScript, for Express 5, a common Node.js web server library. It needs cookie-parser, a middleware plug-in that reads incoming cookies into req.cookies. A plain Map stands in for the session store.

typescript
import { randomBytes } from "node:crypto";
import type { Request, Response } from "express";

const IDLE_MS = 5 * 60 * 1000;          // 5 minutes without a request
const ABSOLUTE_MS = 8 * 60 * 60 * 1000; // 8 hours in total, however active
type Session = { userId: string; idleUntil: number; hardUntil: number };
const sessions = new Map<string, Session>(); // Redis or a database in production
const COOKIE = { httpOnly: true, secure: true, sameSite: "strict", path: "/" } as const;

// Call after the password check passes. Needs app.use(cookieParser()).
export function startSession(req: Request, res: Response, userId: string): void {
  const oldId = req.cookies?.["__Host-sid"];
  if (oldId) sessions.delete(oldId);                // drop any pre-login session
  const id = randomBytes(32).toString("base64url"); // 256 random bits, always new
  const now = Date.now();
  sessions.set(id, { userId, idleUntil: now + IDLE_MS, hardUntil: now + ABSOLUTE_MS });
  res.cookie("__Host-sid", id, COOKIE);             // no maxAge: gone when the browser closes
}

// Call at the start of every request. null means: answer 401.
export function currentUser(req: Request): string | null {
  const id = req.cookies?.["__Host-sid"];
  const s = id ? sessions.get(id) : undefined;
  const now = Date.now();
  if (!s || now > s.idleUntil || now > s.hardUntil) {
    if (id) sessions.delete(id);
    return null;
  }
  s.idleUntil = now + IDLE_MS; // activity extends the idle limit, never the hard one
  return s.userId;
}

export function endSession(req: Request, res: Response): void {
  const id = req.cookies?.["__Host-sid"];
  if (id) sessions.delete(id);
  res.clearCookie("__Host-sid", COOKIE); // same options, or the browser ignores it
}

Four parts matter most. First, every login gets a brand-new ID, and any ID the browser sent before login is deleted. That blocks session fixation, where an attacker plants a known ID before login and reuses it after.

Second, randomBytes(32) gives 256 random bits, far above the 64-bit minimum. Third, the cookie options turn on every protection the browser offers, and the next sections explain each one.

Fourth, the server enforces two time limits itself. OWASP lists 2 to 5 minutes as a common idle timeout for high-value apps, and asks for an absolute timeout on every session. The 8-hour limit here is the team's own choice.

The cookie has no maxAge, so the browser treats it as a session cookie and usually drops it on close. The server's limits are what really end the session, because browsers that restore tabs can restore session cookies too.

How to check your session cookie

Log in once with curl -i against your own login route and read the response headers. You should see one line like this: Set-Cookie: __Host-sid=VALUE; Path=/; HttpOnly; Secure; SameSite=Strict. With this code, VALUE is 43 characters long.

Then open your site, open the browser console and type document.cookie. The session cookie should not appear at all. In our Chromium 141 test it printed an empty string, while fetch requests still carried the cookie to the server.

We also ran the helper with a fake clock. A session left idle for 6 minutes was refused. A session used every 4 minutes still ended after about 8 hours, at the absolute limit.

How does token-based login with a JWT work?

With token-based login, the server signs a token that holds the user's ID and an expiry time, and the client sends it on each request. The server checks the signature and the expiry, and needs no session lookup.

RFC 7519, the standard that defines JWTs, describes them as a compact, URL-safe way to pass claims, which are facts about a user. A signed JWT has three parts joined by dots. The header names the signing method, the payload holds the claims, and the signature covers both.

The bank's first token carried three standard claims. The sub claim is the subject, here the customer ID. The iat claim says when it was issued, and exp says when it stops being valid. Both are counted in seconds since 1 January 1970.

One fact surprises many beginners. The OWASP JWT Cheat Sheet points out that the payload is only base64url encoded, a reversible way to write bytes as text, not encrypted. Anyone holding the token can read every claim, so never put an account number or a balance in a signed-only token.

You can check this yourself with Node.js. The command below prints the payload of any JWT without knowing the key.

bash
# Put a real token in place of TOKEN. No key is needed to read it.
node -e 'console.log(Buffer.from(process.argv[1].split(".")[1], "base64url").toString())' TOKEN
# Prints something like:
# {"sub":"cust-1001","iat":1792486554,"exp":1793091354}

Here exp minus iat is 604,800 seconds, which is exactly 7 days. That gap is the token's lifetime. Short of a denylist, covered in the logout section, or a signing-key change that cancels every token, the server cannot shorten it once the token is out.

  • What tokens do well: many separate services can check a token without calling a shared session store.
  • Where tokens fit: mobile apps and server-to-server calls, where no web page script can reach the stored token.
  • What tokens make hard: cancelling one login early, because the server keeps no record to delete.
  • What to always check: the signature, the expiry, and that the signing method is one you expect.

Is it safe to store a JWT in localStorage?

No, not for an app that moves money or holds personal data. Any script running on your site can read localStorage, so one XSS bug or one bad third-party script can copy the token and use it elsewhere.

localStorage belongs to one origin, meaning one scheme, host and port. Its data has no expiry time, and every tab open on that origin shares it. In our test, a script in a second tab read a token saved by the first.

The OWASP Session Management Cheat Sheet is blunt about it, as of 19 October 2026. It says not to keep session IDs, JWTs, refresh tokens or any credential in localStorage or sessionStorage.

The diagram shows the review's attack. Start at the payee nickname and follow the arrows.

How XSS steals a token from localStorage

Notice that the bank's server never sees anything odd: the stolen token carries a valid signature, so the attacker's calls look like the customer's.

Diagram connections:

  • Payee nickname with script to Bank page runs the script
  • Bank page runs the script to localStorage (Reads the token)
  • localStorage to Attacker's server (Token sent out)
  • Attacker's server to Bank API (Calls as the customer)
  • Payee nickname with script: Shown on the page without escaping
  • Bank page runs the script: Same powers as the app's own code
  • localStorage: Holds the 7-day token in plain text
  • Attacker's server: Receives a copy of the token
  • Bank API: Sees a valid signature and trusts it

Moving the token somewhere cleverer helps less than people hope. sessionStorage is tied to one tab, but scripts on that page can still read it. A JavaScript variable in memory is lost on every reload, and injected code may still reach it.

The IETF, the body that writes internet standards, published its advice for OAuth in browsers as RFC 10017 in August 2026. Its section 8 reviews each storage option. For localStorage, sessionStorage and IndexedDB, a larger in-browser database, it says none can fully stop token theft.

A service worker, a background script the page cannot read, can keep tokens away from page scripts. Injected code can still unregister it, and it has no private storage that lasts. A web worker can protect the long-lived refresh token used to get new tokens. It cannot protect the short-lived access token the page itself sends to APIs.

RFC 10017 also names a worse problem. A script running in your page can ask the login server for brand-new tokens, exactly as your own code would. Even fully isolated storage cannot prevent that.

That is still a big win. With the cookie, an attacker must act while the victim's page is open, and the session dies at its timeout or at logout. With a stolen seven-day token, the attacker has a week.

Set HttpOnly, Secure and SameSite on every login cookie, leave out the Domain attribute, and start the cookie name with the __Host- prefix. Together they stop scripts reading it, keep it off plain HTTP, and limit when other sites can trigger it.

These settings are called attributes. The server adds them in the Set-Cookie response header. Here is what each one does, following the MDN Web Docs page on Set-Cookie and our Chromium 141 tests.

  • HttpOnly: page scripts cannot read the cookie, though the browser still sends it with fetch requests.
  • Secure: the browser sends the cookie only over HTTPS, so nobody on public Wi-Fi can read it in transit. Browsers make an exception for localhost during development.
  • SameSite=Strict: the cookie goes only with requests that start on your own site. Site means the registrable domain, such as example.com, so a sibling subdomain counts as your site.
  • SameSite=Lax: it also goes on top-level navigations from other sites, such as a link click, but only with safe methods such as GET, never POST.
  • No SameSite at all: Chromium treats the cookie as Lax, with one gap. MDN says such cookies still go on POST requests for 2 minutes after being set. In our Chromium 141 test, that held for a cross-site form submission, not a cross-site fetch.
  • No Domain attribute: the cookie stays with the exact host that set it, not every subdomain.
  • No Max-Age or Expires: the cookie is a session cookie that the browser drops when it closes, unless the browser restores the session.

We checked the SameSite rows on Chromium 141, with the bank's site on localhost and a second site on 127.0.0.1. A hidden form on the second site posted to the bank. Only the cookie without a SameSite attribute arrived, seconds after it was set.

A link click from the second site carried the Lax cookie and the cookie without SameSite, but not the Strict one. Cookies withheld on a cross-site image request and a form POST sent more than 2 minutes after setting confirmed that Chromium treats a cookie without SameSite as Lax.

Strict has a cost. A customer who follows a link from another site, such as webmail, arrives looking logged out on that first page. For a bank, that is a fair price.

What the __Host- name prefix adds

A cookie name that starts with __Host- makes the browser enforce three rules. The cookie must be Secure, must have Path=/ and must not have a Domain. A browser that supports the prefix rejects any such cookie that breaks a rule.

Chromium 141 did exactly that in our test. It dropped a __Host- cookie without Secure, one with a Domain, and one with Path=/app. The correct one was kept.

A newer prefix, __Host-Http-, adds a fourth rule: the cookie must be HttpOnly, so only a server response can set it. Chromium 141 rejected one without HttpOnly and one set from JavaScript. RFC 10017 says a BFF SHOULD use such a prefix and gives __Host-Http- as its example. We tested only Chromium, so check other browsers' support as of your release date.

How cookies open the door to CSRF

The browser attaches cookies by itself, and that is what CSRF abuses. Suppose a logged-in customer visits a bad website. That site could submit a hidden form to the bank, and a cookie without the right SameSite setting would ride along.

SameSite blocks much of this, but the OWASP CSRF Prevention Cheat Sheet calls it a defence-in-depth control that does not replace a proper CSRF defence in most deployments. One reason: SameSite works per site, so a compromised sibling subdomain counts as same-site.

So the team adds one more check on every request that changes data. The front end sends a custom header, and the server rejects writes without it.

typescript
export async function sendMoney(toAccount: string, amountRupees: number): Promise<void> {
  const res = await fetch("/api/transfers", {
    method: "POST",
    credentials: "same-origin",          // the default, written out: send the session cookie
    headers: {
      "Content-Type": "application/json",
      "X-Requested-By": "bank-web",      // server rejects writes without it
    },
    body: JSON.stringify({ toAccount, amountRupees }),
  });
  if (res.status === 401) {
    window.location.assign("/login");    // session ended: sign in again
    return;
  }
  if (!res.ok) throw new Error(`Transfer failed with status ${res.status}`);
}

Notice what is missing. There is no token and no Authorization header anywhere in this code. The browser attaches the cookie, and the script never touches it. Same-origin is already the fetch default, but writing it makes the intent clear.

Why does a header help? A plain HTML form on another site cannot set custom headers. A script on another site can try, but the browser first sends a CORS preflight. CORS (cross-origin resource sharing) is the browser's permission check, and the bank's server approves no other origins.

We tested this on Chromium 141. A script on the second site called the transfer API with the custom header and credentials included. Only the preflight OPTIONS request reached the bank, and the POST was never sent.

The header only protects you if the server checks it on every POST, PUT and DELETE, and if CORS never approves other origins. OWASP also warns that XSS can defeat every CSRF defence, so escaping output still comes first.

How do you log a user out with sessions and with tokens?

With a server session, you delete the session record and the login ends at once. A self-contained token stays valid until it expires, so you keep its lifetime short and, to cancel one early, check a list of revoked tokens.

Go back to the water park. Striking out a line in the ledger kills that tube chit at once. A lost season pass is different. To stop it early, the park must give the slide attendants a list of lost pass numbers, and they must check it.

That list is called a denylist. The OWASP JWT Cheat Sheet says a denylist can be keyed on the jti claim, a unique token ID, and the iss claim, which names the issuer. Its example stores each entry with the token's expiry time.

Notice what has happened. Checking a denylist on every request is a server lookup, just like a session lookup. OWASP warns that sessions then stop being completely stateless, which might defeat the reason tokens were chosen.

The bank needs instant logout in several cases. Here is how the team handles each one with server sessions.

  1. The customer clicks Log out

    The server deletes the session record, then sends the cookie back empty with an expiry date in the past, so the browser drops it too.

  2. The customer changes their password

    The server deletes every session for that customer, so other phones and laptops are logged out.

  3. Support spots a stolen login

    Support deletes that one session from the store. The attacker's next request gets a 401 (not logged in) error and lands on the login page.

  4. The customer walks away

    After five idle minutes the server treats the session as expired, even if the browser still has the cookie.

The logout bug that leaves the cookie behind

Pause and predict: a logout route calls res.clearCookie("__Host-sid") with the cookie name only. Does the browser drop the cookie?

In Express 5.3.0, res.clearCookie with only the cookie name sends an empty cookie with an expiry date in 1970, but without the Secure flag. Chromium 141 rejected that header, because every __Host- cookie must be Secure.

So the old cookie stayed in the browser. If that route also skips deleting the server record, the user is still logged in. In our test that route did skip it, and the server answered 200 (OK) instead of 401.

The fix is in the endSession function above. It deletes the server record first, then clears the cookie with the same options used to set it. After that, the cookie was gone and sendMoney landed on the login page.

What just happened? The Express docs warn that browsers clear a cookie only when the options match those used to set it. Our test shows the prefix is what made that strict here: a plain Secure cookie without __Host- cleared with its name alone.

What is the backend for frontend pattern for login tokens?

A backend for frontend (BFF) is a small server that sits between your browser app and the APIs. It holds the OAuth tokens itself and gives the browser only an HttpOnly session cookie.

The bank needs this for a new feature: customers can view linked accounts at partner banks. The partner's API accepts only OAuth access tokens, short-lived tokens that let one app call an API for a user.

OAuth is a standard way for one app to get limited access to another service for a user. A refresh token is a longer-lived token used to get new access tokens. Both are valuable, so neither should sit in the browser.

Look at where the tokens live in this diagram.

The bank's app with a backend for frontend

Notice that every token box connects to the BFF, never to the browser. The browser's only secret is one HttpOnly session cookie.

Diagram connections:

  • Net-banking app to Backend for frontend (Cookie on each call)
  • Backend for frontend to Session store (Find session and tokens)
  • Backend for frontend to Login server (Get or refresh tokens)
  • Backend for frontend to Core banking API
  • Backend for frontend to Partner bank API (Access token attached)
  • Net-banking app: Holds only the __Host-sid cookie
  • Backend for frontend: Checks the cookie, adds the right token
  • Session store: Session ID to user and tokens
  • Login server: Issues access and refresh tokens
  • Core banking API: Balances, payees and transfers
  • Partner bank API: Accepts OAuth access tokens only

RFC 10017 compares three ways to run OAuth from a browser app. In the BFF, the tokens never reach the browser. Section 6.1.4.3 strongly recommends it for business apps, sensitive apps and apps that handle personal data.

In a token-mediating backend, a server gets the tokens but hands the access token to the browser, where a script can still steal it. The RFC strongly recommends checking whether a full BFF is possible first.

In a browser-only client, the browser holds every token. Section 6.3.4.3 says that design is not recommended for business apps, sensitive apps or apps that handle personal data.

The RFC also sets rules for the BFF's own cookie. It MUST be Secure and HttpOnly, SHOULD use SameSite=Strict, and SHOULD carry a prefix such as __Host-Http-. The BFF MUST have a CSRF defence, and the RFC suggests requiring a custom header.

The BFF has a cost. It is one more server to run, every API call takes one extra hop, and it needs CSRF defences because it relies on cookies. For a bank, that cost is small next to a stolen login.

The decision diagram sums up the whole guide. Start at the top question and follow your own app's answers.

Choosing where login state lives

Start at the top. Most browser apps end at a session cookie; only apps that call OAuth-protected APIs need the BFF branch.

Diagram connections:

  • Is the client a browser? to Tokens in secure OS storage (No)
  • Is the client a browser? to Calls APIs needing OAuth? (Yes)
  • Calls APIs needing OAuth? to Session in HttpOnly cookie (No, own backend only)
  • Calls APIs needing OAuth? to Backend for frontend (Yes)
  • Is the client a browser?: Web page, not a native mobile app
  • Tokens in secure OS storage: Short-lived access, rotating refresh
  • Calls APIs needing OAuth?: For example, the partner bank API
  • Session in HttpOnly cookie: Random ID, SameSite, CSRF check
  • Backend for frontend: Tokens on the server, cookie in browser

How do you explain cookies vs sessions vs tokens in a system design interview?

Explain it as two choices, where the proof lives and where the browser keeps it, then pick the safest option that fits the app.

  1. Separate the two questions

    Say that a cookie is a carrier. The real choice is server sessions versus self-contained tokens, and then where the browser stores the string.

  2. Name the threats

    XSS can read anything page scripts can reach, such as localStorage. CSRF abuses cookies the browser sends by itself. Every design must answer both.

  3. Give the default

    For a browser app with its own backend, use a random session ID in an HttpOnly, Secure, SameSite cookie whose name starts with __Host-.

  4. Add a BFF when OAuth appears

    If the app calls APIs that need access tokens, keep them on a backend for frontend. The browser still holds only a cookie.

  5. Cover logout and expiry

    Explain idle and absolute timeouts on the server, deleting sessions on password change, and short token lifetimes with a denylist where tokens are used.

  6. Mention the trade-offs

    Sessions need a shared store that every server can reach. A BFF adds a hop. Tokens avoid lookups but make instant logout hard.

Questions people ask

Is a JWT more secure than a session cookie?
No, not by itself. A JWT and a session ID are both secrets that grant access, so what matters is where you keep them and how fast you can cancel them. A session ID in an HttpOnly cookie is usually safer in a browser, because scripts cannot read it and deleting the server record ends it at once.
Can I store a JWT in an HttpOnly cookie?
Yes, and that is much safer than localStorage, because page scripts cannot read it. You now need CSRF defences, since the browser sends it by itself. You also still face the token problem of cancelling it early. Many teams find a plain session ID simpler at that point.
Are cookies bad for privacy?
Login cookies are not the privacy problem people usually mean. Privacy worries are mostly about third-party cookies, set by other domains, that follow you across many websites. A first-party login cookie, set by the site you are using and limited by SameSite, only keeps you signed in there.
Do sessions work when an app runs on many servers?
Yes, if every server reads the same session store. Teams usually keep sessions in a fast shared store such as Redis, or in a database. The other option is sticky sessions, where a load balancer sends each user to the same server. If sessions live in that server's memory, a restart logs those users out.
What is the difference between an access token and a refresh token?
An access token is short-lived and is sent with API calls. A refresh token lives longer and is used only to get a new access token from the login server. Because a refresh token is so powerful, a BFF keeps it on the server and never sends it to the browser.
Does SameSite make CSRF tokens unnecessary?
Not fully. The OWASP CSRF Prevention Cheat Sheet treats SameSite as an extra layer, not the whole defence. Sibling subdomains count as the same site, and Lax still allows state-changing GET requests. Cookies without SameSite also get a 2-minute gap for cross-site form POSTs in Chromium. Add a server check too.
Where should a mobile app store tokens?
A native app should use the secure storage its operating system provides. On iOS that is the Keychain, which stores small secrets. On Android, the Keystore holds encryption keys, so apps can encrypt the token with a Keystore key. Keep access tokens short-lived, so a stolen one is useful only briefly.

Sources and further reading

Practise this with Lala

Practise