Session vs cookie vs token — where state lives in each model
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:
| Model | State lives at | Communication |
|---|---|---|
| Session | Server (Redis/DB) | Session ID in cookie |
| JWT token | Client (inside token) | Authorization: Bearer header |
| Cookie only | Client (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
// 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