0

Session vs cookie vs token — where state lives in each model

Intermediate5 min read·eng-13-008
interviewsecurity

Concept

Session vs cookie vs token — three different models for maintaining state across HTTP requests, which is inherently stateless.

Cookie: A small piece of data the server sends to the browser, which the browser automatically attaches to subsequent requests to the same domain. Max 4KB. Domain and path restricted. Can have Secure (HTTPS only), HttpOnly (no JS access), SameSite (CSRF protection) flags.

Session (cookie-based): The server stores data (the session). The client stores only a session ID in a cookie. Request comes in → server looks up session store (file, Redis, DB) using the ID → reads session data. State lives on the SERVER.

  • Pros: Data never exposed to client. Can store large amounts of data server-side.
  • Cons: Requires shared session storage for horizontal scaling (all servers need access to the same Redis). Stateful.

Token (JWT): A signed string that CONTAINS the state (user ID, claims). Client stores the token (usually in localStorage or a cookie). Client sends it in the Authorization: Bearer <token> header. Server verifies the signature and reads the claims. State lives on the CLIENT (inside the token).

  • Pros: Stateless. Works across any number of servers without shared storage. Perfect for APIs and microservices.
  • Cons: Cannot be invalidated before expiry (without a denylist — making it stateful again). Token payload is visible (base64-encoded, not encrypted by default). Larger than a session ID cookie.

Where each model lives:

ModelState lives atCommunication
SessionServer (Redis/DB)Session ID in cookie
JWT tokenClient (inside token)Authorization: Bearer header
Cookie onlyClient (small data)Automatic by browser

API recommendation: JWT (or opaque tokens via database) for APIs. Session for traditional web apps. Laravel Sanctum uses cookies for SPA, tokens for mobile/3rd-party APIs.

Code Example

php
<?php
// SESSION — state on the server
// Laravel's session middleware handles this
Route::post('/login', function (LoginRequest $request) {
    $request->authenticate(); // validates credentials
    $request->session()->regenerate(); // prevents session fixation
    // Session stores: user ID, CSRF token, flash messages
    return redirect('/dashboard');
});

Route::get('/dashboard', function () {
    $userId = session('user_id'); // read from session store (Redis/file)
    // Laravel uses auth()->user() which reads from session
    return view('dashboard', ['user' => auth()->user()]);
});

// COOKIE — state on the client
Route::get('/preferences', function () {
    $theme = cookie('theme', 'light'); // read cookie
    return response()->view('app', compact('theme'))
        ->cookie('theme', 'dark', 60 * 24 * 30); // set cookie for 30 days
});

// JWT TOKEN — state inside the token
class JwtService
{
    private string $secret;

    public function encode(array $payload): string
    {
        $header  = base64_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
        $claims  = base64_encode(json_encode(array_merge($payload, ['exp' => time() + 3600])));
        $sig     = hash_hmac('sha256', "{$header}.{$claims}", $this->secret, true);
        return "{$header}.{$claims}." . base64_encode($sig);
    }

    public function decode(string $token): array
    {
        [$header, $claims, $sig] = explode('.', $token);
        $expectedSig = base64_encode(hash_hmac('sha256', "{$header}.{$claims}", $this->secret, true));
        if (!hash_equals($expectedSig, $sig)) throw new \RuntimeException('Invalid token');
        $payload = json_decode(base64_decode($claims), true);
        if ($payload['exp'] < time()) throw new \RuntimeException('Token expired');
        return $payload;
    }
}

// Laravel Sanctum — both models in one package
// For SPA (same domain): cookie-based session
// app/Http/Kernel.php: 'web' middleware group

// For APIs (mobile, 3rd party): token in Authorization header
Route::post('/auth/token', function (LoginRequest $request) {
    $request->authenticate();
    $user  = auth()->user();
    $token = $user->createToken('mobile-app', ['read:orders', 'write:orders']);
    return response()->json(['token' => $token->plainTextToken]);
});

Route::middleware('auth:sanctum')->get('/user', fn(Request $r) => $r->user());

// Comparing storage: token payload is visible (NOT encrypted by default!)
$jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo0MiwiZXhwIjoxNzM2MDAwMDAwfQ.SIG';
// Middle part decoded: {"user_id":42,"exp":1736000000} — client can read this!
// Use JWE if you need to encrypt the payload