Skip to main content

JWT Authentication - Advanced Guide

Complete guide to JWT (JSON Web Token) authentication including token generation, validation, refresh tokens, and custom claims.

Table of Contents

Overview

The reference architecture uses JWT tokens for stateless authentication. Tokens are:

  • Self-contained: Contain user information and permissions
  • Stateless: No server-side session storage required
  • Secure: Cryptographically signed to prevent tampering
  • Expirable: Time-limited validity

Key Components

ComponentPurposeLocation
JwtContextToken creation and parsingByJG\Gluo\Util\JwtContext (byjg/gluo-core)
LoginControllerLogin and token endpointssrc/Controller/LoginController.php (contract) extending ByJG\Gluo\Controller\BaseLoginController (logic)
RequireAuthenticatedEndpoint authenticationByJG\Gluo\Attribute\RequireAuthenticated (byjg/gluo-core)
RequireRoleRole-based authorizationByJG\Gluo\Attribute\RequireRole (byjg/gluo-core)

JwtContext Utility

The JwtContext class provides methods for creating tokens and extracting user information.

Location: ByJG\Gluo\Util\JwtContext (byjg/gluo-core)

Available Methods

// Create a UserToken (token + claims) from a UserModel instance or login string
JwtContext::createUserMetadata(UserModel|string $user, string $password = ""): ?UserToken

// Create JWT token with custom data
JwtContext::createToken(array $properties): string

// Store the request (called automatically by RequireAuthenticated/RequireRole)
JwtContext::setRequest(HttpRequest $request): void

// Extract user information from token
JwtContext::getUserId(): ?string
JwtContext::getRole(): ?string
JwtContext::getName(): ?string
JwtContext::getUser(): UserModel

// Clear the stored request/user (long-running workers, test setUp)
JwtContext::reset(): void

Customization Hooks

JwtContext is designed to be subclassed. Override these protected static methods:

protected static function customTokenFields(): array  // extra claims, default []
protected static function tokenExpiry(): int // seconds, default 3600
protected static function defaultRole(): string // role when unset, default 'user'

Login Flow

Login Endpoint

Location: src/Controller/LoginController.php (contract) + ByJG\Gluo\Controller\BaseLoginController (logic)

Your controller only declares the OpenAPI contract and delegates to the framework:

#[OA\Post(path: "/login", tags: ["Login"])]
#[ValidateRequest]
#[Override]
public function post(HttpResponse $response, HttpRequest $request): void
{
parent::post($response, $request);
}

The parent (BaseLoginController::post) validates the credentials through AuthUser via JwtContext::createUserMetadata() and writes token + data to the response. Improvements to this logic arrive with composer update.

Client Login Request

POST /login
Content-Type: application/json

{
"username": "[email protected]",
"password": "secret123"
}

Login Response

{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"data": {
"userid": "550e8400-e29b-41d4-a716-446655440000",
"name": "John Doe",
"role": "admin"
}
}

Using the Token

GET /products
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Token Structure

Default JWT Payload

Location: ByJG\Gluo\Util\JwtContext (byjg/gluo-core)

The default token contains the fields below. UserToken::$data is what ends up inside the JWT:

$tokenFields = array_merge(
[
UserField::Userid,
UserField::Name,
UserField::Role->value => static::defaultRole(), // 'user' when the role is empty
],
static::customTokenFields() // [] by default — your subclass adds claims here
);

You never edit this code — it lives in vendor/. To change the payload, subclass JwtContext and override customTokenFields() (see Custom JWT Claims below).

Decoded Token Example

{
"userid": "550e8400-e29b-41d4-a716-446655440000",
"name": "John Doe",
"role": "admin",
"iat": 1704067200,
"exp": 1704672000
}

Token Components

  • userid: Unique user identifier (UUID)
  • name: User's display name
  • role: User's role (admin/user)
  • iat: Issued At timestamp
  • exp: Expiration timestamp

Custom JWT Claims

Adding Custom Claims

Subclass JwtContext and override the hooks — no need to copy the token creation logic:

<?php

namespace RestReferenceArchitecture\Util;

use ByJG\Authenticate\Enum\UserField;
use ByJG\Gluo\Util\JwtContext;

class CustomJwtContext extends JwtContext
{
protected static function customTokenFields(): array
{
return [
UserField::Email, // built-in extra claim
'department', // custom property (must exist in your model/properties)
];
}

protected static function tokenExpiry(): int
{
return 3600; // 1 hour (this is already the default)
}

// Add getter methods for your claims
public static function getEmail(): ?string
{
return self::getRequestParam("email");
}

public static function getDepartment(): ?string
{
return self::getRequestParam("department");
}

public static function getPermissions(): ?array
{
$perms = self::getRequestParam("permissions");
return $perms ? json_decode($perms, true) : null;
}

public static function getTenantId(): ?string
{
return self::getRequestParam("tenant_id");
}
}

Use UserField enum values for built-in columns (userid, name, email, etc.) and literal strings for custom fields exposed by your User model or users_property table.

Wire It into the Login Flow

BaseLoginController asks for the JwtContext class through a hook. Override it in your src/Controller/LoginController.php:

use ByJG\Gluo\Util\JwtContext;
use Override;
use RestReferenceArchitecture\Util\CustomJwtContext;

class LoginController extends BaseLoginController
{
/**
* @return class-string<JwtContext>
*/
#[Override]
protected function getJwtContextClass(): string
{
return CustomJwtContext::class;
}

// ... OA-annotated endpoint stubs ...
}

Both /login and /refreshtoken now issue tokens with your custom claims.

Using Custom Claims

#[RequireAuthenticated]
public function getMyData(HttpResponse $response, HttpRequest $request): void
{
$userId = CustomJwtContext::getUserId();
$email = CustomJwtContext::getEmail();
$department = CustomJwtContext::getDepartment();
$permissions = CustomJwtContext::getPermissions();
$tenantId = CustomJwtContext::getTenantId();

$response->write([
'userId' => $userId,
'email' => $email,
'department' => $department,
'permissions' => $permissions,
'tenantId' => $tenantId
]);
}

Token Refresh

Refresh Token Endpoint

Location: src/Controller/LoginController.php (contract) + ByJG\Gluo\Controller\BaseLoginController (logic)

Like /login, your controller declares the contract and delegates:

#[OA\Post(path: "/refreshtoken", tags: ["Login"])]
#[RequireAuthenticated]
#[Override]
public function refreshToken(HttpResponse $response, HttpRequest $request): void
{
parent::refreshToken($response, $request);
}

BaseLoginController::refreshToken only allows the refresh in the final 5 minutes before expiration, reloads the user, and issues a fresh token with the same claim set (including your custom claims, if you overrode getJwtContextClass()).

Client Refresh Request

POST /refreshtoken
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Refresh Response

{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"data": {
"userid": "550e8400-e29b-41d4-a716-446655440000",
"name": "John Doe",
"role": "admin"
}
}

Automatic Token Refresh

Implement client-side automatic refresh:

// JavaScript example
let token = localStorage.getItem('jwt_token');
let refreshTimer;

async function refreshToken() {
const response = await fetch('/refreshtoken', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});

const data = await response.json();
token = data.token;
localStorage.setItem('jwt_token', token);

// Schedule next refresh (e.g., 1 hour before expiration)
scheduleRefresh();
}

function scheduleRefresh() {
// Refresh shortly before expiration (login tokens last 1 hour by default — see tokenExpiry())
const refreshIn = (55 * 60 * 1000); // 55 minutes in ms
refreshTimer = setTimeout(refreshToken, refreshIn);
}

// Start refresh cycle
scheduleRefresh();

Protecting Endpoints

Require Authentication

use ByJG\Gluo\Attribute\RequireAuthenticated;

#[OA\Get(path: "/profile", tags: ["User"])]
#[RequireAuthenticated]
public function getProfile(HttpResponse $response, HttpRequest $request): void
{
// Only authenticated users can access
$userId = JwtContext::getUserId();
$name = JwtContext::getName();

$response->write([
'userId' => $userId,
'name' => $name
]);
}

Require Specific Role

use ByJG\Gluo\Attribute\RequireRole;
use RestReferenceArchitecture\Model\User;

#[OA\Delete(path: "/users/{id}", tags: ["Admin"])]
#[RequireRole(User::ROLE_ADMIN)]
public function deleteUser(HttpResponse $response, HttpRequest $request): void
{
// Only admins can access
$id = $request->attribute('id');
// Delete user logic...
}

Multiple Authorization Levels

// Public endpoint - No authentication
#[OA\Get(path: "/products", tags: ["Products"])]
public function listProducts(...) { }

// Authenticated - Any logged-in user
#[OA\Post(path: "/orders", tags: ["Orders"])]
#[RequireAuthenticated]
public function createOrder(...) { }

// Admin only
#[OA\Delete(path: "/products/{id}", tags: ["Products"])]
#[RequireRole(User::ROLE_ADMIN)]
public function deleteProduct(...) { }

Accessing User Information

In REST Controllers

#[RequireAuthenticated]
public function getCurrentUser(HttpResponse $response, HttpRequest $request): void
{
$userId = JwtContext::getUserId();
$name = JwtContext::getName();
$role = JwtContext::getRole();

$response->write([
'id' => $userId,
'name' => $name,
'role' => $role
]);
}

In Services

class OrderService extends BaseService
{
public function createOrder(array $orderData): Order
{
// Get current user from JWT
$userId = JwtContext::getUserId();

// Add user to order
$orderData['user_id'] = $userId;
$orderData['created_by'] = JwtContext::getName();

return $this->create($orderData);
}

public function listMyOrders(): array
{
$userId = JwtContext::getUserId();

$query = $this->repository->listQuery(
filter: [
['user_id = :user_id', ['user_id' => $userId]]
]
);

return $this->repository->getRepository()->getByQuery($query);
}
}

Checking Permissions

class ProductService extends BaseService
{
public function delete(int $productId): void
{
$role = JwtContext::getRole();

// Business rule: Only admins can delete
if ($role !== User::ROLE_ADMIN) {
throw new Error403Exception('Only administrators can delete products');
}

// Additional check: users can only delete their own products
$product = $this->getOrFail($productId);
$userId = JwtContext::getUserId();

if ($role !== User::ROLE_ADMIN && $product->getUserId() !== $userId) {
throw new Error403Exception('You can only delete your own products');
}

parent::delete($productId);
}
}

Token Expiration

Default Expiration

Location: ByJG\Gluo\Util\JwtContext (byjg/gluo-core)

public static function createToken(array $properties = []): mixed
{
$jwt = Config::get(JwtWrapper::class);

// Token valid for 7 days (in seconds)
$expirationTime = 60 * 60 * 24 * 7;

$jwtData = $jwt->createJwtData($properties, $expirationTime);
return $jwt->generateToken($jwtData);
}

Custom Expiration

class CustomJwtContext extends JwtContext
{
public static function createToken(
array $properties = [],
?int $expirationSeconds = null
): mixed {
$jwt = Config::get(JwtWrapper::class);

// Default to 24 hours if not specified
$expirationSeconds = $expirationSeconds ?? (60 * 60 * 24);

$jwtData = $jwt->createJwtData($properties, $expirationSeconds);
return $jwt->generateToken($jwtData);
}

// Short-lived token for sensitive operations
public static function createShortLivedToken(array $properties): mixed
{
return self::createToken($properties, 60 * 15); // 15 minutes
}

// Long-lived token for remember-me
public static function createLongLivedToken(array $properties): mixed
{
return self::createToken($properties, 60 * 60 * 24 * 30); // 30 days
}
}

Handling Expired Tokens

try {
// Token validation happens automatically in RequireAuthenticated
$this->assertRequest($request);
} catch (Error401Exception $e) {
// Token expired or invalid
if (strpos($e->getMessage(), 'expired') !== false) {
// Redirect to refresh token endpoint or login
return ['error' => 'Token expired', 'action' => 'refresh'];
}

return ['error' => 'Unauthorized'];
}

Security Best Practices

1. Store JWT Secret Securely

JWT_SECRET must be a base64-encoded string whose decoded value is at least 64 bytes (required by HS512). Generate one with composer terminal:

APP_ENV=dev composer terminal
php> \ByJG\JwtWrapper\JwtWrapper::generateSecret(64)
# => 'OFbOmC2VxlgQHNrBLa/wyj7/fFkgPnLpckbXMVuIU7Sqb3RTztNx3xzEYaoeA31JUpvBjkD7FRKBFGQ0+fnTig=='

Copy the output into the appropriate config/<env>/credentials.env:

JWT_SECRET=OFbOmC2VxlgQHNrBLa/wyj7/fFkgPnLpckbXMVuIU7Sqb3RTztNx3xzEYaoeA31JUpvBjkD7FRKBFGQ0+fnTig==

Never commit secrets to version control.

2. Use HTTPS Only

Always transmit tokens over HTTPS:

// In production environment config
if (Config::get('environment') === 'prod') {
if (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] !== 'on') {
throw new Error400Exception('HTTPS required');
}
}

3. Validate Token on Every Request

The RequireAuthenticated attribute handles this automatically:

#[RequireAuthenticated]  // Validates token automatically
public function protectedEndpoint(...) { }

4. Short Token Expiration

Use shorter expiration times for sensitive operations:

$metadata = [
'userid' => JwtContext::getUserId(),
'name' => JwtContext::getName(),
'role' => JwtContext::getRole(),
];

// Regular operations: 7 days
$regularToken = JwtContext::createToken($metadata);

// Admin operations: 1 hour
$adminToken = CustomJwtContext::createToken($metadata, 60 * 60);

// Financial operations: 15 minutes
$financialToken = CustomJwtContext::createToken($metadata, 60 * 15);

5. Implement Token Blacklist (Optional)

For logout or compromised tokens:

class TokenBlacklist
{
protected CacheInterface $cache;

public function blacklist(string $token, int $expirationTime): void
{
// Store token in cache until it would expire anyway
$this->cache->set("blacklist:{$token}", true, $expirationTime);
}

public function isBlacklisted(string $token): bool
{
return $this->cache->has("blacklist:{$token}");
}
}

// Custom authentication attribute
class RequireValidToken extends RequireAuthenticated
{
public function processBefore(HttpResponse $response, HttpRequest $request): void
{
parent::processBefore($response, $request);

$token = $this->extractToken($request);
$blacklist = Config::get(TokenBlacklist::class);

if ($blacklist->isBlacklisted($token)) {
throw new Error401Exception('Token has been revoked');
}
}
}

6. Rotate Tokens Regularly

Encourage clients to refresh tokens:

Since your LoginController owns the endpoint, you can replace the response shape instead of delegating to the parent:

class LoginController extends BaseLoginController
{
#[Override]
public function post(HttpResponse $response, HttpRequest $request): void
{
$payload = ValidateRequest::getPayload();
$userToken = JwtContext::createUserMetadata($payload['username'], $payload['password']);

$response->write([
'token' => $userToken->token,
'expires_in' => 60 * 60, // 1 hour (JwtContext default)
'refresh_after' => 55 * 60, // Suggest refresh in the last 5 minutes
'data' => $userToken->data
]);
}
}

7. Validate User Still Exists

Check user validity on critical operations:

#[RequireAuthenticated]
public function deleteAccount(HttpResponse $response, HttpRequest $request): void
{
$userId = JwtContext::getUserId();

// Verify user still exists and is active
$usersService = Config::get(UsersService::class);
$user = $usersService->getById($userId);

if ($user === null || $user->getDeletedAt() !== null) {
throw new Error401Exception('Account is no longer active');
}

// Proceed with deletion...
}

8. Rate Limit Authentication Endpoints

use RestReferenceArchitecture\Attribute\RateLimit; // your custom attribute (not part of gluo-core)

#[OA\Post(path: "/login", tags: ["Login"])]
#[RateLimit(maxRequests: 5, windowSeconds: 60)] // 5 attempts per minute
#[ValidateRequest]
public function post(HttpResponse $response, HttpRequest $request)
{
// Login logic...
}

9. Log Authentication Events

public function post(HttpResponse $response, HttpRequest $request)
{
$json = ValidateRequest::getPayload();
$usersService = Config::get(UsersService::class);

try {
$user = $usersService->isValidUser($json["username"], $json["password"]);

if ($user === null) {
throw new Error401Exception('Invalid credentials');
}

// Log successful login
$logger->info('User logged in', [
'user_id' => $user->getUserid(),
'username' => $json["username"],
'ip' => $_SERVER['REMOTE_ADDR']
]);

// Generate token...

} catch (Error401Exception $e) {
// Log failed login attempt
$logger->warning('Failed login attempt', [
'username' => $json["username"],
'ip' => $_SERVER['REMOTE_ADDR']
]);

throw $e;
}
}

10. Multi-Factor Authentication (Optional)

public function post(HttpResponse $response, HttpRequest $request)
{
$json = ValidateRequest::getPayload();
$usersService = Config::get(UsersService::class);
$user = $usersService->isValidUser($json["username"], $json["password"]);

if ($user === null) {
throw new Error401Exception('Invalid credentials');
}

// Check if MFA is enabled for user via properties table
if ($usersService->hasProperty($user->getUserid(), 'mfa_enabled', 'yes')) {
$tempToken = $this->createTempToken($user);

$response->write([
'mfa_required' => true,
'temp_token' => $tempToken,
'message' => 'Please provide MFA code'
]);
return;
}

$userToken = JwtContext::createUserMetadata($user);
$response->write(['token' => $userToken->token, 'data' => $userToken->data]);
}

#[OA\Post(path: "/login/verify-mfa", tags: ["Login"])]
public function verifyMfa(HttpResponse $response, HttpRequest $request)
{
$json = ValidateRequest::getPayload();

// Verify MFA code
if ($this->verifyMfaCode($json['temp_token'], $json['mfa_code'])) {
$user = $this->getUserFromTempToken($json['temp_token']);
$userToken = JwtContext::createUserMetadata($user);

$response->write(['token' => $userToken->token, 'data' => $userToken->data]);
} else {
throw new Error401Exception('Invalid MFA code');
}
}