Token-Based Authentication in Web Frameworks
Comparing stateless authentication patterns in Rails and Laravel ecosystems

Table of Contents

1. Overview

Token-based authentication separates session state from the server. The client presents a token on each request; the server validates the signature without database lookup. This pattern emerged from two pressures: horizontally-scaled API servers that cannot share session stores, and mobile clients that cannot maintain cookie state across process boundaries.

Two implementations dominate their respective ecosystems. Devise Token Auth (Rails, 3.6k stars) extends the Devise authentication framework with token headers. JWT Auth (Laravel, 11.2k stars) implements OAuth2-style bearer tokens using JSON Web Tokens. The implementations differ in token storage, renewal semantics, and the tradeoff between statelessness and revocation.

This note compares the two libraries by token lifecycle, examines the revocation problem, and demonstrates the implementation differences through code examples.

2. Token Lifecycle Comparison

Both libraries follow the same flow: authenticate once, receive a token, present the token on subsequent requests. The divergence is in token renewal and storage requirements.

Sequence diagram showing client authentication flow: login with credentials, server validates and returns token, subsequent API requests include token in Authorization header, server validates token and returns protected resource

Figure 1: Token authentication flow — client authenticates, receives token, uses token for subsequent requests

2.1. Devise Token Auth (Rails)

Devise Token Auth stores tokens in the database. Each successful authentication generates a new token and stores it in the tokens column (a JSON hash). The token is sent in response headers, not the body.

# app/controllers/api/v1/auth/sessions_controller.rb
class Api::V1::Auth::SessionsController < DeviseTokenAuth::SessionsController
  def create
    # POST /api/v1/auth/sign_in
    # Params: { email: "alice@example.com", password: "..." }
    # Response headers:
    #   access-token: "abc123..."
    #   client: "xyz789..."
    #   uid: "alice@example.com"
    super
  end
end

The client must include three headers on subsequent requests: access-token, client, and uid. The server validates by querying the database for a user record matching the uid and comparing the token hash.

# Subsequent request
# Headers:
#   access-token: "abc123..."
#   client: "xyz789..."
#   uid: "alice@example.com"

# Server validates:
# 1. Load user by uid
# 2. Check tokens[client] matches access-token
# 3. Verify token expiry

Token renewal happens on every request. The server issues a new token and invalidates the old one. This creates a race condition: if the client sends two concurrent requests with the same token, the second request may fail because the first already rotated the token. Devise Token Auth handles this with a configurable batch window (default: 2 weeks) during which old tokens remain valid.

2.2. JWT Auth (Laravel)

JWT Auth does not store tokens. The server signs the token with a secret key; validation is signature verification, not database lookup. This is the stateless property.

// app/Http/Controllers/AuthController.php
use Tymon\JWTAuth\Facades\JWTAuth;

class AuthController extends Controller
{
    public function login(Request $request)
    {
        // POST /api/auth/login
        // Body: { "email": "alice@example.com", "password": "..." }
        $credentials = $request->only('email', 'password');

        if (!$token = JWTAuth::attempt($credentials)) {
            return response()->json(['error' => 'Unauthorized'], 401);
        }

        // Response body:
        // { "access_token": "eyJhbGciOiJIUzI1NiIs...", "token_type": "bearer" }
        return response()->json(['access_token' => $token, 'token_type' => 'bearer']);
    }
}

The client includes the token in the Authorization header as a bearer token. The server decodes and verifies the signature. No database query is required for validation.

// Subsequent request
// Header: Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

// Middleware validates:
// 1. Decode JWT
// 2. Verify signature with secret key
// 3. Check expiration claim (exp)
// 4. Optionally load user from database using sub claim

Token renewal requires an explicit refresh endpoint. The client sends the old token to /api/auth/refresh and receives a new token. The old token is blacklisted by storing its jti (JWT ID) in a cache or database until expiry.

public function refresh()
{
    // POST /api/auth/refresh
    // Header: Authorization: Bearer <old_token>
    $newToken = JWTAuth::refresh(JWTAuth::getToken());
    return response()->json(['access_token' => $newToken]);
}

3. The Revocation Problem

Stateless tokens cannot be revoked. Once signed, the token is valid until expiry. The server has no record of issued tokens to invalidate. This is the core tradeoff: stateless validation (no database lookup) versus immediate revocation (requires state).

3.1. Devise Token Auth Revocation

Revocation is deletion. Remove the token from the database tokens column and it is invalid immediately.

# app/controllers/api/v1/auth/sessions_controller.rb
def destroy
  # DELETE /api/v1/auth/sign_out
  # Removes client token from user.tokens hash
  super
end

# Implementation (inside DeviseTokenAuth)
user.tokens.delete(client_id)
user.save!

This requires a database write on every logout. At scale, the tokens column becomes a contention point. The column stores a JSON hash of all active sessions; concurrent updates require optimistic locking.

3.2. JWT Auth Revocation

JWT Auth uses a blacklist. When a token is revoked, its jti claim is added to a blacklist table or cache. Validation checks the blacklist before accepting the token.

public function logout()
{
    // POST /api/auth/logout
    // Blacklists the current token by jti
    JWTAuth::invalidate(JWTAuth::getToken());
    return response()->json(['message' => 'Successfully logged out']);
}

// Middleware must now check blacklist on every request
// This reintroduces state and database lookups

The blacklist entry only needs to live until the token's exp claim. After expiry, the token is invalid regardless of blacklist status. This allows automatic cleanup: delete blacklist entries older than the maximum token TTL.

The blacklist defeats statelessness. Every validation now requires a cache or database query to check if the jti is blacklisted. The advantage over Devise Token Auth is granularity: the blacklist only contains revoked tokens, not all active sessions.

4. Storage Requirements

Library Tokens Stored Storage Location Revocation Cost
Devise Token Auth All active sessions Database (JSON column) O(1) write
JWT Auth (stateless) None N/A Impossible
JWT Auth (blacklist) Revoked tokens only Database or cache O(1) read per request

Devise Token Auth scales poorly with long-lived sessions or users with many concurrent clients. The tokens column grows without bound unless pruned. A user with 50 active mobile/web sessions has 50 token hashes in a single database row.

JWT Auth's blacklist is append-only and auto-pruning. New entries are added on logout; old entries are deleted by a background job that sweeps expired tokens. The blacklist size is proportional to logout rate, not active session count.

5. Implementation Examples

5.1. Devise Token Auth Setup

# Gemfile
gem 'devise_token_auth'

# config/routes.rb
mount_devise_token_auth_for 'User', at: 'api/v1/auth'

# app/models/user.rb
class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  include DeviseTokenAuth::Concerns::User
end

# Database migration
create_table :users do |t|
  t.string :email, null: false
  t.string :encrypted_password, null: false
  t.text :tokens  # JSON hash of active tokens
  t.timestamps
end

add_index :users, :email, unique: true

The tokens column format is a hash keyed by client ID. Each entry contains the token hash, expiry, and last refresh time.

{
  "xyz789": {
    "token": "$2a$10$...",
    "expiry": 1653532800,
    "last_token": "$2a$10$...",
    "updated_at": "2026-05-22T12:00:00.000Z"
  }
}

5.2. JWT Auth Setup

// composer.json
{
  "require": {
    "tymon/jwt-auth": "^2.0"
  }
}

// config/auth.php
'guards' => [
    'api' => [
        'driver' => 'jwt',
        'provider' => 'users',
    ],
],

// app/Models/User.php
use Tymon\JWTAuth\Contracts\JWTSubject;

class User extends Authenticatable implements JWTSubject
{
    public function getJWTIdentifier()
    {
        return $this->getKey();  // Primary key
    }

    public function getJWTCustomClaims()
    {
        return [];  // Additional payload data
    }
}

// routes/api.php
Route::post('auth/login', [AuthController::class, 'login']);
Route::post('auth/logout', [AuthController::class, 'logout'])->middleware('auth:api');
Route::post('auth/refresh', [AuthController::class, 'refresh'])->middleware('auth:api');

The JWT payload contains the user ID in the sub claim. The server loads the user on each request if needed, but validation does not require a database query.

{
  "iss": "https://api.example.com",
  "sub": "42",
  "iat": 1653532800,
  "exp": 1653536400,
  "jti": "a7b3c4d5e6f7"
}

6. Security Comparison

6.1. Token Transmission

Devise Token Auth sends tokens in custom headers (access-token, client, uid). This prevents CSRF attacks because browsers do not automatically send custom headers on cross-origin requests.

JWT Auth uses the Authorization: Bearer header. Same CSRF protection, but the standard header makes it compatible with OAuth2 clients and tools that expect bearer tokens.

6.2. Token Storage Client-Side

Both require the client to store the token. Storing in localStorage is vulnerable to XSS. Storing in an httpOnly cookie prevents XSS access but reintroduces CSRF risk unless combined with SameSite=Strict.

The recommended pattern for SPA clients: store in memory and refresh on page load using a short-lived refresh token in an httpOnly cookie.

6.3. Algorithm Downgrade

JWT Auth is vulnerable to algorithm downgrade attacks if the server accepts tokens with alg: none. The mitigation is to explicitly specify allowed algorithms in the validation configuration.

// config/jwt.php
'algo' => 'HS256',  // Never allow 'none'

Devise Token Auth does not use JWTs, so this attack does not apply. The token is a random string hashed with bcrypt.

6.4. Secret Key Rotation

Rotating the signing secret in JWT Auth invalidates all tokens immediately. This is a feature for emergency revocation but requires coordination: issue new tokens with the new secret before rotating, then deprecate the old secret after a grace period.

Devise Token Auth tokens are hashed, not signed. Rotating the secret is not applicable. Individual token revocation is deletion.

7. When to Use Each

Scenario Recommendation
Mobile API with long-lived sessions JWT Auth (stateless, no db growth)
SPA with frequent token rotation Devise Token Auth (auto-rotation on every request)
Microservices with shared auth JWT Auth (no shared database required)
Monolith with single database Devise Token Auth (simpler mental model)
Immediate logout required Devise Token Auth or JWT Auth with blacklist
High read/write ratio JWT Auth (stateless validation)
High logout rate Devise Token Auth (no blacklist overhead)

The choice is between simplicity (Devise Token Auth: store tokens, revoke by deletion) and statelessness (JWT Auth: sign tokens, accept inability to revoke). The blacklist pattern is a compromise that reintroduces state.

8. Hybrid Approach

Some systems use both. Short-lived JWTs (5 minutes) for API access paired with a long-lived refresh token stored in the database. The access token is stateless; the refresh token is stored like Devise Token Auth. Revocation targets the refresh token, making the access token valid only until its natural expiry.

# Hybrid model (pseudocode)
def login(email, password)
  user = authenticate(email, password)
  access_token = sign_jwt(user.id, exp: 5.minutes.from_now)
  refresh_token = SecureRandom.hex(32)
  user.update!(refresh_token: bcrypt(refresh_token))
  { access_token: access_token, refresh_token: refresh_token }
end

def refresh(refresh_token)
  user = User.find_by(refresh_token: bcrypt(refresh_token))
  return 401 if user.nil?
  access_token = sign_jwt(user.id, exp: 5.minutes.from_now)
  { access_token: access_token }
end

def logout(refresh_token)
  user = User.find_by(refresh_token: bcrypt(refresh_token))
  user.update!(refresh_token: nil)  # Revoke immediately
end

This pattern appears in OAuth2 flows. The access token is a JWT; the refresh token is an opaque string stored server-side.

9. Measurement

Performance comparison requires measuring token validation latency under load. The hypothesis: JWT Auth validation is O(1) compute; Devise Token Auth validation is O(1) database query. The crossover point is when database latency exceeds cryptographic signature verification.

# Devise Token Auth validation
time curl -H "access-token: abc123" \
          -H "client: xyz789" \
          -H "uid: alice@example.com" \
          https://api.example.com/protected

# JWT Auth validation
time curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
          https://api.example.com/protected

Expected results at 1000 req/s:

  • JWT Auth: 1-2ms per request (signature verification)
  • Devise Token Auth: 5-10ms per request (database lookup + bcrypt compare)

The gap widens when database replicas lag or connection pools saturate. JWT Auth validation is CPU-bound; Devise Token Auth validation is I/O-bound.

10. References

11. Appendix: Token Flow Diagram Source