Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Cookies vs Tokens: Where Should Your Web App Keep the Session?

About Post

Somewhere right now, a tutorial is telling a new developer to save the login token in localStorage. Three lines of code, works first time, ships to production. And it's the one storage place that any script on the page can read.

"Cookies vs tokens" is one of the most argued-about topics in web development, partly because the question itself is slightly wrong. Let's fix the question first, then answer it for the cases you'll actually meet: a classic web app, a single-page app, and a mobile app.

The question is really "where" and "how"

A cookie is not the opposite of a token. A cookie is a storage and transport mechanism: the browser stores it and automatically attaches it to requests for that site. A token is just a credential, a string that proves who you are. You can put a token inside a cookie.

So the real choice is between two models:

  • Browser-managed: the credential lives in an HttpOnly cookie. Your JavaScript never touches it; the browser sends it automatically.
  • App-managed: your code stores the token somewhere (often localStorage) and adds it to each request as an Authorization: Bearer ... header.

Each model is vulnerable to a different attack, and that's the heart of the whole debate.

The localStorage problem: XSS

Anything in localStorage can be read by any JavaScript running on your page. That includes your code, but also a compromised npm package, a third-party widget, or an attacker who found one unescaped input field (a cross-site scripting bug, XSS).

With one line, that script can read the token and send it to the attacker's server. Now they can use it from their own machine, for as long as the token stays valid. You might not notice for a long time.

An HttpOnly cookie can't be read by JavaScript at all. To be clear, XSS is still very bad with cookies: the attacker's script can make requests as the user while the page is open. But they can't walk away with the credential. That's a meaningful reduction in damage.

The cookie problem: CSRF

The thing that makes cookies convenient also makes them risky. Because the browser attaches them automatically, another website could try to trigger a request to your site, and the user's cookie would ride along. That's cross-site request forgery (CSRF).

The good news is that CSRF is a well-understood, largely solved problem:

  • SameSite cookies. With SameSite=Lax (Laravel's default for its session cookie), the browser doesn't send the cookie on cross-site POST requests. Strict goes further.
  • CSRF tokens. The server requires a secret value that a foreign site can't read. Laravel's @csrf and its XSRF-TOKEN cookie handle this for you.
  • Secure flag. The cookie is only ever sent over HTTPS.

Bearer tokens in headers don't have the CSRF problem, because the browser never adds them automatically. But they trade it for the XSS exfiltration problem above.

Side by side

HttpOnly cookieToken in localStorage
Readable by JavaScriptNoYes
If there's an XSS bugAttacker acts as the user only while the page is openAttacker can steal the token and reuse it elsewhere
CSRF riskYes, mitigated with SameSite and CSRF tokensNo
Works across different domainsAwkwardEasy
Works for native mobile appsNot the natural fitYes, but use secure storage instead
Best forWeb apps and SPAs on your own domainRarely the best choice in a browser

My rule: browser gets cookies, apps get tokens

Rule of thumb: if your frontend runs in a browser on the same site as your API, use HttpOnly session cookies. If your client is a mobile app or a third-party service, use tokens, stored in the platform's secure storage.

Mobile apps don't share the browser's cookie model, and they don't have the same XSS exposure. A token stored in the iOS Keychain or Android Keystore (in Expo, via expo-secure-store) and sent in the Authorization header is the standard approach.

How Laravel Sanctum does both

Laravel Sanctum is a nice example of this rule in practice, because it has two modes in one package.

Mode 1: SPA authentication (cookies)

For a React or Vue frontend on the same top-level domain as your API, Sanctum uses Laravel's normal session cookies. The SPA first fetches a CSRF cookie, then logs in:

axios.defaults.withCredentials = true;
axios.defaults.withXSRFToken = true;

await axios.get('/sanctum/csrf-cookie');
await axios.post('/login', { email, password });

// From now on, the session cookie is sent automatically
const { data } = await axios.get('/api/user');

On the Laravel 11 side, you enable this with $middleware->statefulApi() in bootstrap/app.php and list your frontend domain in SANCTUM_STATEFUL_DOMAINS. No token ever touches JavaScript.

Mode 2: API tokens (mobile apps and integrations)

For a mobile app, Sanctum issues tokens. A simplified login endpoint, close to the one in the docs:

Route::post('/mobile/token', function (Request $request) {
    $request->validate([
        'email'       => 'required|email',
        'password'    => 'required',
        'device_name' => 'required',
    ]);

    $user = User::where('email', $request->email)->first();

    if (! $user || ! Hash::check($request->password, $user->password)) {
        throw ValidationException::withMessages([
            'email' => ['The provided credentials are incorrect.'],
        ]);
    }

    return ['token' => $user->createToken($request->device_name)->plainTextToken];
});

Sanctum stores only a hash of the token in the database, so a database leak doesn't expose usable tokens. Because tokens are stored server-side, you can revoke one device ("log out of my old phone") by deleting its row. You'd also want rate limiting on this route in production.

A quick word on JWTs

JWTs are often what people mean by "tokens". They're self-contained and signed, so the server can verify them without a database lookup. The flip side: once issued, a JWT is valid until it expires, and revoking one early needs extra machinery like a deny list. For most first-party apps, a server-side session or a database-backed token like Sanctum's is simpler and easier to revoke. JWTs shine more when separate services need to verify identity independently.

The wrap-up

  • Cookies vs tokens is really browser-managed vs app-managed credentials.
  • localStorage turns any XSS into a stolen credential. Avoid it for auth tokens.
  • HttpOnly + Secure + SameSite cookies, plus CSRF protection, is the strong default for browser apps.
  • Mobile apps use tokens in secure storage, sent as Bearer headers.
  • Sanctum gives you both, so you don't have to choose one for everything.

The Sanctum documentation covers both modes in detail, including the domain configuration that trips most people up.

Where do your apps keep the session today, and was that a deliberate choice or something a tutorial decided for you?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close