Refresh Tokens

Melodic supports secure refresh token rotation for long-lived sessions. The framework provides the token model, service logic, middleware, and cookie helper. Your application provides the database repository.

How It Works

Login → Issue JWT (short-lived, ~15 min) + Refresh Token (cookie, ~7 days)
  ↓
JWT expires → Client hits /auth/refresh → Middleware validates cookie
  ↓
Controller rotates refresh token → New JWT + new refresh token cookie
  ↓
Old refresh token is revoked — if anyone replays it, the entire family is revoked

Key security properties:

  • Refresh tokens are opaque random strings, not JWTs
  • Stored as SHA-256 hashes in the database — the raw value is never persisted
  • Sent only as HTTP-only cookies — never in response bodies, never accessible to JavaScript
  • Rotated on every use — each refresh issues a new token and revokes the old one
  • Family-based reuse detection — if a revoked token is presented, all tokens in the family are revoked

Configuration

Add a refreshToken section inside auth in your config.json:

{
    "auth": {
        "local": {
            "signingKey": "your-256-bit-secret",
            "issuer": "my-app",
            "audience": "my-app",
            "tokenLifetime": 900
        },
        "refreshToken": {
            "tokenLifetime": 604800,
            "cookieName": "kingdom_refresh",
            "cookieDomain": ".example.com",
            "cookiePath": "/auth/refresh",
            "cookieSecure": true,
            "cookieSameSite": "Lax"
        },
        "providers": { }
    }
}

Settings

KeyTypeDefaultDescription
tokenLifetime int 604800 Refresh token lifetime in seconds (default: 7 days)
cookieName string kingdom_refresh Name of the HTTP-only cookie
cookieDomain string "" Cookie domain (e.g. .example.com for subdomains)
cookiePath string /auth/refresh Cookie path — scoped to the refresh endpoint
cookieSecure bool true Require HTTPS (false for local development)
cookieSameSite string Lax SameSite attribute (Lax, Strict, or None)

Tip: For local development, set cookieSecure to false in config.dev.json:

{ "auth": { "refreshToken": { "cookieSecure": false } } }

Database Table

The framework does not create tables. Create this table in your application's database (adjust syntax for your RDBMS).

SQL Server

CREATE TABLE RefreshTokens (
    Id          INT IDENTITY(1,1) PRIMARY KEY,
    UserId      INT NOT NULL,
    TokenHash   VARCHAR(64) NOT NULL,
    FamilyId    VARCHAR(36) NOT NULL,
    Generation  INT NOT NULL DEFAULT 1,
    ExpiresAt   DATETIME2 NOT NULL,
    RevokedAt   DATETIME2 NULL,
    CreatedAt   DATETIME2 NOT NULL DEFAULT GETUTCDATE(),

    INDEX IX_RefreshTokens_TokenHash (TokenHash),
    INDEX IX_RefreshTokens_FamilyId (FamilyId),
    INDEX IX_RefreshTokens_UserId (UserId)
);

MySQL

CREATE TABLE refresh_tokens (
    id          INT AUTO_INCREMENT PRIMARY KEY,
    user_id     INT NOT NULL,
    token_hash  VARCHAR(64) NOT NULL,
    family_id   VARCHAR(36) NOT NULL,
    generation  INT NOT NULL DEFAULT 1,
    expires_at  DATETIME NOT NULL,
    revoked_at  DATETIME NULL,
    created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,

    INDEX idx_token_hash (token_hash),
    INDEX idx_family_id (family_id),
    INDEX idx_user_id (user_id)
);

Implementing the Repository

Your application must implement RefreshTokenRepositoryInterface and bind it in the DI container. The framework never touches your database directly.

Repository Interface

interface RefreshTokenRepositoryInterface
{
    public function findByTokenHash(string $hash): ?RefreshToken;
    public function findLatestGenerationByFamily(string $familyId): int;
    public function store(RefreshToken $token): void;
    public function revokeByFamily(string $familyId): void;
    public function revokeByUserId(int $userId): void;
    public function deleteExpired(): int;
}

Example Implementation

<?php

declare(strict_types=1);

namespace App\Data\RefreshToken;

use Melodic\Data\DbContextInterface;
use Melodic\Security\RefreshToken;
use Melodic\Security\RefreshTokenRepositoryInterface;

class RefreshTokenRepository implements RefreshTokenRepositoryInterface
{
    public function __construct(
        private readonly DbContextInterface $context,
    ) {
    }

    public function findByTokenHash(string $hash): ?RefreshToken
    {
        $row = $this->context->queryFirst(
            null,
            'SELECT * FROM RefreshTokens WHERE TokenHash = :hash',
            ['hash' => $hash],
        );

        return $row ? $this->hydrate($row) : null;
    }

    public function findLatestGenerationByFamily(string $familyId): int
    {
        return (int) $this->context->scalar(
            'SELECT MAX(Generation) FROM RefreshTokens WHERE FamilyId = :familyId',
            ['familyId' => $familyId],
        );
    }

    public function store(RefreshToken $token): void
    {
        $this->context->command(
            'INSERT INTO RefreshTokens (UserId, TokenHash, FamilyId, Generation, ExpiresAt, RevokedAt, CreatedAt)
             VALUES (:userId, :tokenHash, :familyId, :generation, :expiresAt, :revokedAt, :createdAt)',
            [
                'userId' => $token->userId,
                'tokenHash' => $token->tokenHash,
                'familyId' => $token->familyId,
                'generation' => $token->generation,
                'expiresAt' => $token->expiresAt->format('Y-m-d H:i:s'),
                'revokedAt' => $token->revokedAt?->format('Y-m-d H:i:s'),
                'createdAt' => $token->createdAt->format('Y-m-d H:i:s'),
            ],
        );
    }

    public function revokeByFamily(string $familyId): void
    {
        $this->context->command(
            'UPDATE RefreshTokens SET RevokedAt = :now WHERE FamilyId = :familyId AND RevokedAt IS NULL',
            ['now' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), 'familyId' => $familyId],
        );
    }

    public function revokeByUserId(int $userId): void
    {
        $this->context->command(
            'UPDATE RefreshTokens SET RevokedAt = :now WHERE UserId = :userId AND RevokedAt IS NULL',
            ['now' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), 'userId' => $userId],
        );
    }

    public function deleteExpired(): int
    {
        return $this->context->command(
            'DELETE FROM RefreshTokens WHERE ExpiresAt < :now',
            ['now' => (new \DateTimeImmutable())->format('Y-m-d H:i:s')],
        );
    }

    private function hydrate(object $row): RefreshToken
    {
        return new RefreshToken(
            id: (int) $row->Id,
            userId: (int) $row->UserId,
            tokenHash: $row->TokenHash,
            familyId: $row->FamilyId,
            generation: (int) $row->Generation,
            expiresAt: new \DateTimeImmutable($row->ExpiresAt),
            revokedAt: $row->RevokedAt ? new \DateTimeImmutable($row->RevokedAt) : null,
            createdAt: new \DateTimeImmutable($row->CreatedAt),
        );
    }
}

Registering the Repository

Bind your repository in the DI container. The SecurityServiceProvider registers RefreshTokenService and RefreshTokenMiddleware automatically, but they depend on RefreshTokenRepositoryInterface being bound by your app.

<?php

declare(strict_types=1);

namespace App\Providers;

use Melodic\DI\Container;
use Melodic\DI\ServiceProvider;
use Melodic\Security\RefreshTokenRepositoryInterface;
use App\Data\RefreshToken\RefreshTokenRepository;

class AppServiceProvider extends ServiceProvider
{
    public function register(Container $container): void
    {
        $container->bind(
            RefreshTokenRepositoryInterface::class,
            RefreshTokenRepository::class,
        );
    }
}

Important: Register AppServiceProvider before SecurityServiceProvider in your entry point, so the binding is available when the security services are resolved.

Routes and Controller

Refresh Endpoint

Add a refresh route with RefreshTokenMiddleware applied. The middleware reads the refresh token from the cookie, validates it, and sets request attributes before the controller runs:

use Melodic\Security\ApiAuthenticationMiddleware;
use Melodic\Security\RefreshTokenMiddleware;

$router->group('/auth', function (Router $router) {
    $router->post('/refresh', AuthController::class, 'refresh');
}, middleware: [RefreshTokenMiddleware::class]);

$router->group('/api', function (Router $router) {
    $router->apiResource('/users', UserController::class);
}, middleware: [ApiAuthenticationMiddleware::class]);

Note: RefreshTokenMiddleware is route-specific, not global. Only apply it to your refresh endpoint — it returns a 401 JSON response if no refresh token cookie is present.

Auth Controller

<?php

declare(strict_types=1);

namespace App\Controllers;

use Melodic\Controller\ApiController;
use Melodic\Http\Response;
use Melodic\Security\RefreshToken;
use Melodic\Security\RefreshTokenConfig;
use Melodic\Security\RefreshTokenCookieHelper;
use Melodic\Security\RefreshTokenService;

class AuthController extends ApiController
{
    public function __construct(
        private readonly RefreshTokenService $refreshTokenService,
        private readonly RefreshTokenConfig $refreshTokenConfig,
        private readonly JwtIssuer $jwtIssuer,  // your app's JWT issuing logic
    ) {
    }

    public function login(): Response
    {
        // Validate credentials (your logic)
        $user = $this->authenticateUser();

        // Issue short-lived JWT
        $jwt = $this->jwtIssuer->issue($user);

        // Create refresh token
        $result = $this->refreshTokenService->create($user->id);

        // Attach refresh token cookie to response
        $response = $this->json(['token' => $jwt]);
        return RefreshTokenCookieHelper::attach($response, $result['token'], $this->refreshTokenConfig);
    }

    public function refresh(): Response
    {
        // RefreshTokenMiddleware already validated the cookie and set attributes
        /** @var RefreshToken $currentToken */
        $currentToken = $this->request->getAttribute('refreshToken');
        $userId = $this->request->getAttribute('refreshTokenUserId');

        // Rotate the refresh token
        $result = $this->refreshTokenService->rotate($currentToken);

        // Issue a new short-lived JWT
        $jwt = $this->jwtIssuer->issueForUserId($userId);

        // Respond with new JWT + new refresh token cookie
        $response = $this->json(['token' => $jwt]);
        return RefreshTokenCookieHelper::attach($response, $result['token'], $this->refreshTokenConfig);
    }

    public function logout(): Response
    {
        // Revoke all refresh tokens for this user
        $userContext = $this->getUserContext();
        if ($userContext?->isAuthenticated()) {
            $this->refreshTokenService->revokeAllForUser((int) $userContext->getUser()->id);
        }

        // Clear the refresh token cookie
        $response = $this->json(['message' => 'Logged out.']);
        return RefreshTokenCookieHelper::clear($response, $this->refreshTokenConfig);
    }
}

Token Lifecycle

1. Login — Create Tokens

// Issue JWT (short-lived, e.g. 15 minutes)
$jwt = $this->jwtIssuer->issue($user);

// Create refresh token (long-lived, e.g. 7 days)
$result = $this->refreshTokenService->create($user->id);
// $result['token'] → raw opaque token (goes in cookie)
// $result['model'] → RefreshToken object (stored as hash in DB)

// Attach cookie
$response = RefreshTokenCookieHelper::attach($response, $result['token'], $config);

2. Refresh — Rotate Tokens

// RefreshTokenMiddleware validates the cookie automatically
$currentToken = $request->getAttribute('refreshToken');

// Rotate: revokes old, creates new with incremented generation
$result = $this->refreshTokenService->rotate($currentToken);

// Attach new cookie + issue new JWT
$response = RefreshTokenCookieHelper::attach($response, $result['token'], $config);

3. Logout — Revoke All

// Revoke all refresh tokens for the user
$this->refreshTokenService->revokeAllForUser($userId);

// Clear the cookie
$response = RefreshTokenCookieHelper::clear($response, $config);

4. Cleanup — Delete Expired

Run periodically (cron job, scheduled task) to clean up expired tokens:

$deleted = $repository->deleteExpired();

Reuse Detection

Refresh tokens use family-based rotation to detect token theft:

  1. On login, a new family (UUID) is created with generation 1
  2. Each refresh increments the generation and revokes all previous tokens in the family
  3. If someone presents a revoked token (e.g. an attacker replaying a stolen token), the entire family is revoked — this also invalidates the legitimate user's current token, forcing re-authentication
  4. If a token with a stale generation (lower than the latest) is presented, the entire family is also revoked

This means:

  • Normal use: Each refresh works once, then the old token is invalid
  • Stolen token replayed: The family is revoked, both attacker and user must re-authenticate
  • Race condition: If two requests try to refresh simultaneously, the second one triggers reuse detection

API Reference

RefreshToken

Immutable value object representing a stored refresh token.

PropertyTypeDescription
idintDatabase primary key
userIdintID of the user who owns this token
tokenHashstringSHA-256 hash of the raw token
familyIdstringUUID grouping related tokens into a rotation chain
generationintIncremented on each rotation (starts at 1)
expiresAtDateTimeImmutableWhen the token expires
revokedAt?DateTimeImmutableWhen the token was revoked (null if active)
createdAtDateTimeImmutableWhen the token was created
MethodReturn TypeDescription
isExpired()boolReturns true if expiresAt is in the past
isRevoked()boolReturns true if revokedAt is not null

RefreshTokenService

Core service for creating, validating, and rotating refresh tokens. Registered as a singleton by SecurityServiceProvider.

MethodSignatureDescription
create() create(int $userId): array Generates a cryptographic random token, hashes it with SHA-256, stores via the repository. Returns ['token' => $rawToken, 'model' => $refreshToken].
validate() validate(string $rawToken): RefreshToken Looks up the token by hash. Checks revocation (triggers family revocation on reuse), expiration, and stale generation. Throws SecurityException on failure.
rotate() rotate(RefreshToken $current): array Revokes the entire family, creates a new token with the same familyId and generation + 1. Returns ['token' => $rawToken, 'model' => $refreshToken].
revokeAllForUser() revokeAllForUser(int $userId): void Revokes all refresh tokens for the given user (used on logout).

RefreshTokenMiddleware

Route-specific middleware that validates the refresh token cookie before the controller runs.

  • Reads the raw token from the cookie specified by RefreshTokenConfig::cookieName
  • Validates via RefreshTokenService::validate()
  • On success, sets two request attributes: refreshToken (the RefreshToken object) and refreshTokenUserId (the user's ID)
  • On failure (missing cookie, invalid token, expired, reuse detected), returns a 401 JSON response

RefreshTokenCookieHelper

Static utility for attaching and clearing the refresh token cookie on responses. Always sets httponly = true.

MethodSignatureDescription
attach() attach(Response $response, string $rawToken, RefreshTokenConfig $config): Response Sets the refresh token cookie on the response using the config's cookie settings.
clear() clear(Response $response, RefreshTokenConfig $config): Response Clears the cookie by setting it with an expired timestamp.

RefreshTokenConfig

Value object holding refresh token configuration. Created from the auth.refreshToken config section via RefreshTokenConfig::fromArray().

PropertyTypeDefaultDescription
tokenLifetimeint604800Token lifetime in seconds (7 days)
cookieNamestringkingdom_refreshCookie name
cookieDomainstring""Cookie domain
cookiePathstring/auth/refreshCookie path
cookieSecurebooltrueHTTPS-only flag
cookieSameSitestringLaxSameSite attribute

Full Working Example

1. Config (config/config.json)

{
    "app": { "name": "Kingdom" },
    "database": {
        "dsn": "sqlsrv:Server=localhost;Database=Kingdom"
    },
    "auth": {
        "api": { "enabled": true },
        "web": { "enabled": true },
        "local": {
            "signingKey": "replace-with-a-random-256-bit-key",
            "issuer": "kingdom",
            "audience": "kingdom",
            "tokenLifetime": 900
        },
        "refreshToken": {
            "tokenLifetime": 604800,
            "cookieName": "kingdom_refresh",
            "cookieDomain": "",
            "cookiePath": "/auth/refresh",
            "cookieSecure": true,
            "cookieSameSite": "Lax"
        },
        "providers": {
            "local": {
                "type": "local",
                "label": "Sign in"
            }
        }
    }
}

2. Repository

See the example implementation above.

3. Service Provider (src/Providers/AppServiceProvider.php)

<?php

declare(strict_types=1);

namespace App\Providers;

use Melodic\DI\Container;
use Melodic\DI\ServiceProvider;
use Melodic\Security\LocalAuthenticatorInterface;
use Melodic\Security\RefreshTokenRepositoryInterface;
use App\Data\RefreshToken\RefreshTokenRepository;
use App\Security\AppAuthenticator;

class AppServiceProvider extends ServiceProvider
{
    public function register(Container $container): void
    {
        $container->bind(LocalAuthenticatorInterface::class, AppAuthenticator::class);
        $container->bind(RefreshTokenRepositoryInterface::class, RefreshTokenRepository::class);
    }
}

4. Routes

use Melodic\Security\AuthCallbackMiddleware;
use Melodic\Security\ApiAuthenticationMiddleware;
use Melodic\Security\RefreshTokenMiddleware;

return function (Router $router): void {
    // Auth endpoints
    $router->group('/auth', function (Router $router) {
        $router->get('/login', AuthController::class, 'index');
        $router->get('/login/{provider}', AuthController::class, 'index');
        $router->get('/callback/{provider}', AuthController::class, 'index');
        $router->post('/callback/{provider}', AuthController::class, 'index');
        $router->get('/logout', AuthController::class, 'logout');
    }, middleware: [AuthCallbackMiddleware::class]);

    // Refresh endpoint (only RefreshTokenMiddleware, no JWT auth needed)
    $router->post('/auth/refresh', AuthController::class, 'refresh',
        middleware: [RefreshTokenMiddleware::class],
    );

    // Protected API routes
    $router->group('/api', function (Router $router) {
        $router->apiResource('/users', UserController::class);
    }, middleware: [ApiAuthenticationMiddleware::class]);
};

5. Entry Point (public/index.php)

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use Melodic\Core\Application;
use Melodic\Security\SecurityServiceProvider;
use App\Providers\AppServiceProvider;

$app = new Application(__DIR__ . '/..');
$app->loadConfig('config/config.json');

// Register app provider first (binds RefreshTokenRepositoryInterface)
$app->register(new AppServiceProvider());
$app->register(new SecurityServiceProvider());

$app->routes(require __DIR__ . '/../config/routes.php');

$app->run();