JSON Web Tokens

Introduction

Slenix includes a lightweight JWT (JSON Web Token) implementation for stateless authentication. JWTs allow your application to authenticate users across requests without storing session data on the server — making them ideal for APIs, SPAs, and mobile applications.

The Jwt class is located at Slenix\Supports\Security\Jwt and uses the HS256 algorithm (HMAC-SHA256) to sign and verify tokens.


Configuration

The JWT secret key is read from your .env file. Generate a secure secret before deploying to production:

bash
php celestial jwt:secret

This writes a random 64-character secret to your .env file:

dotenv
JWT_SECRET_TOKEN=your-secure-random-secret-here

You may also pass the secret directly when instantiating the class:

php
use Slenix\Supports\Security\Jwt;

$jwt = new Jwt('my-custom-secret');

If neither the constructor argument nor the environment variable is set, the class falls back to a default placeholder secret — never use this in production.


Generating Tokens

Use the generate() method to create a signed JWT. Pass a payload array containing any data you want to embed in the token:

php
use Slenix\Supports\Security\Jwt;

$jwt   = new Jwt();
$token = $jwt->generate(['user_id' => 42]);

The token automatically includes two standard claims:

ClaimDescription
iatIssued At — Unix timestamp of when the token was created
expExpiration — Unix timestamp after which the token is invalid

Custom Expiration

By default, tokens expire after 1 hour (3600 seconds). Pass a custom duration as the second argument:

php
// Expires in 7 days
$token = $jwt->generate(['user_id' => 42], 60 * 60 * 24 * 7);

// Expires in 15 minutes
$token = $jwt->generate(['user_id' => 42], 60 * 15);

// Expires in 30 days (remember-me)
$token = $jwt->generate(['user_id' => 42, 'remember' => true], 60 * 60 * 24 * 30);

Embedding Custom Claims

You may embed any JSON-serialisable data in the payload:

php
$token = $jwt->generate([
    'user_id' => $user->id,
    'email'   => $user->email,
    'role'    => $user->role,
    'scopes'  => ['read:users', 'write:posts'],
]);

Security note: The JWT payload is base64-encoded, not encrypted. Do not store sensitive data such as passwords, credit card numbers, or private keys in the token.


Validating Tokens

The validate() method verifies the token's signature and checks that it has not expired. It returns the decoded payload on success, or null if the token is invalid or expired:

php
$payload = $jwt->validate($token);

if ($payload === null) {
    // Token is invalid, tampered with, or expired
    $response->error('Unauthorized.', 401);
    return;
}

// Token is valid — use the payload
$userId = $payload['user_id'];
$role   = $payload['role'];

What validate() Checks

  1. The token has exactly three parts (header, payload, signature).
  2. The signature matches — the token has not been tampered with.
  3. The exp claim has not passed — the token has not expired.
  4. The payload is valid JSON and contains an exp claim.

If any check fails, validate() returns null without throwing an exception, making it safe to call in middleware without try/catch.


Using JWT in Routes

The most common pattern is to protect API routes with a JWT middleware. Slenix includes a jwt middleware alias out of the box:

php
use Slenix\Http\Routing\Router;

// Single route
Router::get('/api/me', [Api\UserController::class, 'me'])
    ->middleware('jwt');

// Group of protected API routes
Router::group(['prefix' => 'api/v1', 'middleware' => ['jwt']], function () {
    Router::get('/users',         [Api\UserController::class, 'index']);
    Router::get('/users/{id}',    [Api\UserController::class, 'show']);
    Router::post('/users',        [Api\UserController::class, 'store']);
    Router::put('/users/{id}',    [Api\UserController::class, 'update']);
    Router::delete('/users/{id}', [Api\UserController::class, 'destroy']);
});

Writing the JWT Middleware

The JwtMiddleware validates the Authorization header on every request to a protected route. If the token is missing or invalid, it responds with 401 Unauthorized without calling the next handler:

php
namespace App\Middlewares;

use Slenix\Http\Request;
use Slenix\Http\Response;
use Slenix\Http\Middlewares\Middleware;
use Slenix\Supports\Security\Jwt;

class JwtMiddleware implements Middleware
{
    public function handle(Request $request, Response $response, callable $next): mixed
    {
        $header = $request->getHeader('Authorization', '');

        if (!str_starts_with($header, 'Bearer ')) {
            $response->error('Authorization token required.', 401);
            return null;
        }

        $token = substr($header, 7);
        $jwt   = new Jwt();
        $payload = $jwt->validate($token);

        if ($payload === null) {
            $response->error('Token invalid or expired.', 401);
            return null;
        }

        // Make the payload available to downstream handlers
        $request->setAttribute('jwt_payload', $payload);
        $request->setAttribute('user_id', $payload['user_id'] ?? null);

        return $next($request, $response);
    }
}

Inside the route handler, read the authenticated user's data from the request attributes:

php
Router::get('/api/me', function ($request, $response) {
    $userId  = $request->getAttribute('user_id');
    $payload = $request->getAttribute('jwt_payload');

    $response->json(['user_id' => $userId, 'claims' => $payload]);
})->middleware('jwt');

Login Flow Example

A typical login endpoint generates a token after verifying the user's credentials:

php
namespace App\Controllers;

use Slenix\Http\Request;
use Slenix\Http\Response;
use Slenix\Supports\Security\Jwt;
use App\Models\User;

class AuthController
{
    public function login(Request $request, Response $response): void
    {
        $email    = $request->input('email');
        $password = $request->input('password');

        $user = User::where('email', $email)->first();

        if (!$user || !password_verify($password, $user->password)) {
            $response->error('Invalid credentials.', 401);
            return;
        }

        $jwt   = new Jwt();
        $token = $jwt->generate([
            'user_id' => $user->id,
            'email'   => $user->email,
            'role'    => $user->role,
        ], 60 * 60 * 8); // 8 hours

        $response->success([
            'access_token' => $token,
            'token_type'   => 'Bearer',
            'expires_in'   => 60 * 60 * 8,
        ], 'Login successful.');
    }
}

The client then stores the token and sends it on every subsequent request:

js
// Storing the token
localStorage.setItem('token', data.access_token);

// Sending on every request
fetch('/api/me', {
    headers: {
        'Authorization': 'Bearer ' + localStorage.getItem('token'),
        'Content-Type':  'application/json',
    },
});

Token Structure

A JWT consists of three base64url-encoded parts separated by dots:

plaintext
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9   ← Header
.eyJ1c2VyX2lkIjo0MiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDB9   ← Payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c   ← Signature

Header — algorithm and token type:

json
{ "alg": "HS256", "typ": "JWT" }

Payload — your data plus standard claims:

json
{
  "user_id": 42,
  "email": "claudio@example.com",
  "role": "admin",
  "iat": 1700000000,
  "exp": 1700003600
}

SignatureHMAC-SHA256(base64url(header) + "." + base64url(payload), secret).

The signature is what prevents tampering. Changing any part of the header or payload invalidates it.


Security Considerations

Store the secret securely. The JWT_SECRET_TOKEN must be long, random, and kept private. Anyone who knows the secret can forge tokens.

Use HTTPS. JWTs transmitted over plain HTTP can be intercepted. Always use TLS in production.

Keep tokens short-lived. Prefer short expiration times (15 minutes to 8 hours) and implement token refresh for long-lived sessions rather than issuing tokens that expire in 30+ days.

Do not store sensitive data in the payload. The payload is encoded, not encrypted — it can be decoded by anyone who holds the token.

Invalidation. JWTs are stateless — there is no built-in revocation mechanism. To invalidate a token before its expiry (e.g. on logout), you must maintain a server-side denylist or switch to short-lived tokens with a refresh token pattern.


Method Reference

MethodReturnsDescription
new Jwt(secret?)JwtInstantiate with optional secret (falls back to JWT_SECRET_TOKEN env var)
generate(payload, expiresIn)stringGenerate a signed JWT. Default TTL: 3600 seconds
validate(token)`array\null`Verify signature and expiry. Returns payload or null