Hard Security

JWT vs Server Sessions: Revocation, Token Storage, Refresh Rotation and Laravel Sanctum

JWTs verify without a database, which is also why you cannot revoke them. When to use JWT, where to store tokens, refresh rotation with reuse detection, Sanctum.

13 Nov, 2026 8 min Updated 23 Oct, 2026 7 Code blocks
Diagram
sequenceDiagram participant C as Client participant X as Attacker participant A as API participant S as Token store C->>A: Refresh with token R1 A->>S: Mark R1 used, issue R2 in same family A-->>C: New access token and R2 X->>A: Replay stolen R1 A->>S: R1 already used, revoke whole family A-->>X: 401 Unauthorized C->>A: Refresh with R2 A-->>C: 401, please log in again

A team ships a new API with JWT auth because "sessions do not scale". Six months later a customer reports a stolen laptop. Support clicks "log out all devices", and nothing happens: the access tokens are valid for 30 days and the server has no way to invalidate them. The fix they bolt on is a revoked_tokens table checked on every request, which is a session store with extra steps and a bigger cookie.

JWTs are a good tool for a specific job. They are also the most over-applied auth technology in web development. Before picking one, it is worth being precise about what a JWT gives you, what it takes away, and what the boring alternatives (server sessions and opaque tokens, which Laravel Sanctum gives you for free) already solve.

What a JWT Actually Is

A JSON Web Token is three base64url segments: a header, a payload of claims, and a signature over the first two.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsImV4cCI6MTc2MzAyMjUwMH0.<signature>

header  = {"alg":"HS256","typ":"JWT"}
payload = {"sub":"42","exp":1763022500}         <- base64url, NOT encrypted
signature = HMAC-SHA256(secret, base64url(header) + "." + base64url(payload))

The signature proves the token was issued by someone holding the key and was not modified. That is all. The payload is readable by anyone who holds the token, so never put secrets or personal data in it. Standard claims you should always set and always check: exp (expiry), iss (issuer), aud (audience), sub (subject), and usually iat and jti (a unique id).

The property that makes JWTs attractive is that verification is stateless: any service with the key (HS256) or the public key (RS256, ES256, EdDSA) can validate the token without a database or network call. That is valuable when many independent services must authenticate the same caller, or when an identity provider issues tokens for APIs it does not run (OAuth 2.0 / OpenID Connect).

Server Sessions: The Boring Default

With a session, the browser holds a random, meaningless id in a cookie. The server looks it up in Redis or the database on each request. You get:

  • Instant revocation: delete the session row and the user is out, on every device if you want.
  • Small cookies: 40 bytes instead of a 1 KB token on every request.
  • Server-side state you can change without reissuing anything: roles, impersonation, 2FA status.

The cost is one key-value lookup per request, which Redis does in well under a millisecond. "Sessions do not scale" was true for sticky in-memory sessions on a single server in 2008; with a shared Redis store it is not a real constraint for the vast majority of apps.

The Revocation Problem

A JWT is valid until exp, no matter what happens on the server. Password changed? Account banned? Token leaked into a log file? It keeps working. There are only three ways out, and each gives up some statelessness:

  1. Short lifetimes: access tokens of 5 to 15 minutes, so a leaked token dies quickly.
  2. A denylist of jti values (or a per-user "tokens issued before X are invalid" timestamp) checked on each request. Now you have a lookup again.
  3. Refresh tokens that are stored server-side and can be revoked, so the user cannot get a new access token once you say no.

The production pattern combines 1 and 3: short-lived access tokens plus long-lived, rotating, revocable refresh tokens.

Access + Refresh Token Rotation

Every time the client uses a refresh token, the server marks it used and issues a new one in the same family. If an already-used refresh token ever shows up again, either the client has a bug or an attacker copied it. You cannot tell which copy is legitimate, so you revoke the whole family and force a fresh login.

sequenceDiagram participant C as Client participant X as Attacker participant A as API participant S as Token store C->>A: Refresh with token R1 A->>S: Mark R1 used, issue R2 in same family A-->>C: New access token and R2 X->>A: Replay stolen R1 A->>S: R1 already used, revoke whole family A-->>X: 401 Unauthorized C->>A: Refresh with R2 A-->>C: 401, please log in again

Here is a Laravel implementation. The refresh_tokens table has user_id, family_id, token_hash, used_at, revoked_at and expires_at (cast to datetime on the model).

namespace App\Auth;

use App\Models\RefreshToken;
use App\Models\User;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

final class RefreshTokenService
{
    public function issue(User $user, ?string $familyId = null): string
    {
        $plain = Str::random(64);

        RefreshToken::query()->create([
            'user_id' => $user->id,
            'family_id' => $familyId ?? (string) Str::uuid(),
            'token_hash' => hash('sha256', $plain), // store only a hash, like Sanctum does
            'expires_at' => now()->addDays(14),
        ]);

        return $plain;
    }

    /** @return array{0: User, 1: string} */
    public function rotate(string $plain): array
    {
        $result = DB::transaction(function () use ($plain): array|string {
            $token = RefreshToken::query()
                ->where('token_hash', hash('sha256', $plain))
                ->lockForUpdate()
                ->first();

            if ($token === null) {
                return 'invalid';
            }

            if ($token->used_at !== null) {
                // Someone presented an already-rotated token: assume theft.
                RefreshToken::query()
                    ->where('family_id', $token->family_id)
                    ->update(['revoked_at' => now()]);

                return 'reuse';
            }

            if ($token->revoked_at !== null || $token->expires_at->isPast()) {
                return 'invalid';
            }

            $token->update(['used_at' => now()]);

            return [$token->user, $this->issue($token->user, $token->family_id)];
        });

        // Throw AFTER commit, otherwise the family revocation is rolled back.
        if (is_string($result)) {
            throw new AuthenticationException("Refresh token {$result}.");
        }

        return $result;
    }
}

Note the subtle bug this avoids: throwing an exception inside DB::transaction() rolls the transaction back, which would undo the family revocation exactly when you need it. Returning a status and throwing after the commit keeps the revocation. lockForUpdate() stops two parallel refresh calls (a double click, two browser tabs) from both rotating the same token.

The refresh endpoint issues a 15-minute Sanctum token as the access token and puts the refresh token in a cookie the JavaScript cannot read:

// routes/api.php
use App\Auth\RefreshTokenService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/auth/refresh', function (Request $request, RefreshTokenService $refresh) {
    [$user, $newRefresh] = $refresh->rotate((string) $request->cookie('refresh_token'));

    $access = $user->createToken('access', ['*'], now()->addMinutes(15))->plainTextToken;

    return response()
        ->json(['access_token' => $access, 'expires_in' => 900])
        // name, value, minutes, path, domain, secure, httpOnly, raw, sameSite
        ->cookie('refresh_token', $newRefresh, 60 * 24 * 14, '/api/auth', null, true, true, false, 'strict');
})->middleware('throttle:20,1');

Where to Store Tokens in the Browser

  • localStorage / sessionStorage: readable by any JavaScript on the page. One XSS bug, one compromised npm dependency or analytics script, and every token is exfiltrated and usable from the attacker's machine.
  • HttpOnly; Secure; SameSite cookie: JavaScript cannot read it, so XSS can still use the session while the page is open but cannot steal it. Cookies bring CSRF back into play, which SameSite=Lax or Strict plus Laravel's CSRF token handles.
  • In memory (a JS variable): a reasonable home for a short-lived access token in an SPA. It disappears on reload, and the refresh cookie gets a new one.

Scope the refresh cookie with Path=/api/auth so it is only sent to the refresh endpoint, not with every API call.

If you sign JWTs in Node or at the edge, use a maintained library and pin the algorithm:

import { SignJWT, jwtVerify } from 'jose';

const secret = new TextEncoder().encode(process.env.JWT_SECRET!);

export async function issueAccessToken(userId: string): Promise<string> {
  return new SignJWT({ scope: 'orders:read' })
    .setProtectedHeader({ alg: 'HS256' })
    .setSubject(userId)
    .setIssuer('https://api.example.com')
    .setAudience('orders-service')
    .setIssuedAt()
    .setExpirationTime('15m')
    .setJti(crypto.randomUUID())
    .sign(secret);
}

export async function verifyAccessToken(token: string) {
  const { payload } = await jwtVerify(token, secret, {
    algorithms: ['HS256'], // never let the token choose its own algorithm
    issuer: 'https://api.example.com',
    audience: 'orders-service',
    clockTolerance: 30,
  });
  return payload;
}

Laravel Sanctum: Usually What You Want

Sanctum covers the two common cases without JWTs at all. Install it with php artisan install:api.

For a first-party SPA on the same top-level domain, Sanctum uses ordinary session cookies. No tokens in JavaScript, revocation is "delete the session", and CSRF is handled by the /sanctum/csrf-cookie flow.

// bootstrap/app.php: first-party SPA on the same site uses plain session cookies
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(api: __DIR__.'/../routes/api.php')
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->statefulApi(); // SANCTUM_STATEFUL_DOMAINS=app.example.com
    })
    ->create();

For mobile apps and third-party clients, Sanctum issues opaque personal access tokens. They are random strings stored as SHA-256 hashes in personal_access_tokens, checked with one indexed query, and deleted to revoke. You can give each token abilities and an expiry, and prune old rows with php artisan sanctum:prune-expired.

// Mobile app or third-party client: opaque, revocable Sanctum tokens
$token = $user->createToken('ios-app', ['orders:read'], now()->addMinutes(15));

return ['access_token' => $token->plainTextToken, 'expires_in' => 900];

// Logout this device / everywhere
$request->user()->currentAccessToken()->delete();
$request->user()->tokens()->delete();

Testing Refresh Rotation

The behaviour that matters most is the one nobody exercises manually: replaying an old refresh token must kill the family. With RefreshDatabase enabled in tests/Pest.php:

use App\Auth\RefreshTokenService;
use App\Models\RefreshToken;
use App\Models\User;
use Illuminate\Auth\AuthenticationException;

it('rotates once and burns the whole family on reuse', function () {
    $service = app(RefreshTokenService::class);
    $first = $service->issue(User::factory()->create());

    [, $second] = $service->rotate($first);
    expect($second)->not->toBe($first);

    expect(fn () => $service->rotate($first))
        ->toThrow(AuthenticationException::class, 'Refresh token reuse.');

    // The legitimate-looking newer token dies with its family.
    expect(fn () => $service->rotate($second))->toThrow(AuthenticationException::class);
    expect(RefreshToken::query()->whereNull('revoked_at')->count())->toBe(0);
});

it('rejects expired refresh tokens', function () {
    $service = app(RefreshTokenService::class);
    $token = $service->issue(User::factory()->create());

    $this->travel(15)->days();

    expect(fn () => $service->rotate($token))->toThrow(AuthenticationException::class);
});

Also add a feature test that the refresh cookie is HttpOnly and Secure (assertCookie() plus inspecting the Set-Cookie header), because a refactor that drops those flags will not break any functional test.

Common Pitfalls

  • Trusting the token's alg header. Libraries that honoured alg: none, or accepted an RS256 public key as an HS256 secret, caused real breaches. Always pass an explicit algorithm allowlist.
  • Long-lived access tokens. A 30-day JWT is a 30-day password nobody can change. Keep access tokens to minutes.
  • Not validating aud and iss. A token issued for service A will otherwise be accepted by service B that shares the key.
  • Personal data in the payload. Base64url is encoding, not encryption. Emails, roles and internal ids are visible to anyone holding the token.
  • Refresh tokens without rotation or storage. A stateless refresh token is just a long-lived access token with extra steps.
  • Weak HS256 secrets. HMAC keys can be brute-forced offline from a single token. Use at least 256 random bits, never a word like secret.
  • Using JWTs as sessions for one monolith. You pay the revocation and size costs and get nothing for them.

When to Use JWT (and When Not To)

  • Use JWTs for OAuth 2.0 / OpenID Connect with an external identity provider, for service-to-service auth across many services that should not share a database, and for short-lived, single-purpose tokens (signed download links, email verification) where statelessness genuinely helps.
  • Use server sessions (Sanctum SPA mode) for a first-party web frontend talking to its own Laravel backend. It is simpler and more secure.
  • Use opaque tokens (Sanctum personal access tokens) for mobile apps and API keys, where you want per-device revocation with a single indexed lookup.
  • Combine short-lived JWT access tokens with rotating, stored refresh tokens only when you really need stateless verification downstream and still need the ability to log someone out.

The question to ask is not "JWT or sessions?" but "who needs to verify this credential, and how fast must I be able to revoke it?" If the answer is "only my app" and "immediately", you want a session.

Interactive challenge

Try the challenge

Practice what you just learned. Write your solution, reveal hints if you get stuck.

Instructions

Implement RefreshTokenStore (PHP and Python) with refresh token rotation and reuse detection. The constructor takes a clock (returns the current Unix time as int), an id generator (returns a new unique string each call) and ttlSeconds / ttl_seconds (default 1209600). issue(userId) starts a new family (use one generated id as the family id) and returns a new token in it; each token expires at clock() + ttl. rotate(token): if the token is unknown throw InvalidTokenException (Python: InvalidTokenError); if it was already used, revoke its whole family and throw TokenReuseException (Python: TokenReuseError); if its family is revoked or clock() is greater than or equal to its expiry, throw InvalidTokenException; otherwise mark it used and return a new token in the same family with a fresh expiry. revokeUser(userId) / revoke_user(user_id) revokes every family belonging to that user. Use only the injected clock and id generator.

Starter code

php
final class InvalidTokenException extends RuntimeException {}
final class TokenReuseException extends RuntimeException {}

final class RefreshTokenStore
{
    public function __construct(
        private Closure $clock,
        private Closure $idGenerator,
        private int $ttlSeconds = 1209600,
    ) {}

    public function issue(string $userId): string
    {
        // TODO: start a new family and return a new token in it
        throw new LogicException('TODO');
    }

    public function rotate(string $token): string
    {
        // TODO: unknown -> InvalidTokenException
        // TODO: already used -> revoke its family, TokenReuseException
        // TODO: family revoked or clock() >= expiry -> InvalidTokenException
        // TODO: mark used, return a new token in the same family
        throw new LogicException('TODO');
    }

    public function revokeUser(string $userId): void
    {
        // TODO: revoke every family that belongs to $userId
        throw new LogicException('TODO');
    }
}
python
from typing import Callable


class InvalidTokenError(Exception):
    pass


class TokenReuseError(Exception):
    pass


class RefreshTokenStore:
    def __init__(self, clock: Callable[[], int], id_generator: Callable[[], str], ttl_seconds: int = 1209600) -> None:
        self.clock = clock
        self.id_generator = id_generator
        self.ttl_seconds = ttl_seconds

    def issue(self, user_id: str) -> str:
        # TODO: start a new family and return a new token in it
        raise NotImplementedError

    def rotate(self, token: str) -> str:
        # TODO: unknown -> InvalidTokenError
        # TODO: already used -> revoke its family, TokenReuseError
        # TODO: family revoked or clock() >= expiry -> InvalidTokenError
        # TODO: mark used, return a new token in the same family
        raise NotImplementedError

    def revoke_user(self, user_id: str) -> None:
        # TODO: revoke every family that belongs to user_id
        raise NotImplementedError

Your solution

Hints

All hints revealed
Hint 1

Store per token: user, family, expiry and a used flag; keep revoked families in a separate set

Hint 2

Check 'already used' before 'revoked or expired' so a replayed token always triggers the family revocation

Hint 3

Rotation does not create a new family: the new token inherits the old token's family id

Solution · php
final class InvalidTokenException extends RuntimeException {}
final class TokenReuseException extends RuntimeException {}

final class RefreshTokenStore
{
    /** @var array<string, array{user: string, family: string, expires: int, used: bool}> */
    private array $tokens = [];

    /** @var array<string, true> */
    private array $revokedFamilies = [];

    public function __construct(
        private Closure $clock,
        private Closure $idGenerator,
        private int $ttlSeconds = 1209600,
    ) {}

    public function issue(string $userId): string
    {
        return $this->mint($userId, ($this->idGenerator)());
    }

    public function rotate(string $token): string
    {
        $record = $this->tokens[$token] ?? throw new InvalidTokenException('Unknown refresh token.');

        if ($record['used']) {
            $this->revokedFamilies[$record['family']] = true;
            throw new TokenReuseException('Refresh token reused, family revoked.');
        }

        if (isset($this->revokedFamilies[$record['family']]) || ($this->clock)() >= $record['expires']) {
            throw new InvalidTokenException('Refresh token revoked or expired.');
        }

        $this->tokens[$token]['used'] = true;

        return $this->mint($record['user'], $record['family']);
    }

    public function revokeUser(string $userId): void
    {
        foreach ($this->tokens as $record) {
            if ($record['user'] === $userId) {
                $this->revokedFamilies[$record['family']] = true;
            }
        }
    }

    private function mint(string $userId, string $family): string
    {
        $token = ($this->idGenerator)();
        $this->tokens[$token] = [
            'user' => $userId,
            'family' => $family,
            'expires' => ($this->clock)() + $this->ttlSeconds,
            'used' => false,
        ];

        return $token;
    }
}
Solution · python
from typing import Callable


class InvalidTokenError(Exception):
    pass


class TokenReuseError(Exception):
    pass


class RefreshTokenStore:
    def __init__(self, clock: Callable[[], int], id_generator: Callable[[], str], ttl_seconds: int = 1209600) -> None:
        self.clock = clock
        self.id_generator = id_generator
        self.ttl_seconds = ttl_seconds
        self.tokens: dict[str, dict] = {}
        self.revoked_families: set[str] = set()

    def issue(self, user_id: str) -> str:
        return self._mint(user_id, self.id_generator())

    def rotate(self, token: str) -> str:
        record = self.tokens.get(token)
        if record is None:
            raise InvalidTokenError("unknown refresh token")
        if record["used"]:
            self.revoked_families.add(record["family"])
            raise TokenReuseError("refresh token reused, family revoked")
        if record["family"] in self.revoked_families or self.clock() >= record["expires"]:
            raise InvalidTokenError("refresh token revoked or expired")
        record["used"] = True
        return self._mint(record["user"], record["family"])

    def revoke_user(self, user_id: str) -> None:
        for record in self.tokens.values():
            if record["user"] == user_id:
                self.revoked_families.add(record["family"])

    def _mint(self, user_id: str, family: str) -> str:
        token = self.id_generator()
        self.tokens[token] = {
            "user": user_id,
            "family": family,
            "expires": self.clock() + self.ttl_seconds,
            "used": False,
        }
        return token