Adding Authentication
In this tutorial you will add JWT-based authentication to an existing Melodic application. You will protect API routes with bearer tokens, protect web routes with cookie-based sessions, and access user information in controllers and templates.
What you will build: A secured application where API endpoints require a bearer token, web pages redirect unauthenticated users to a login page, and controllers can check user identity and entitlements.
Step 1: Register the SecurityServiceProvider
Melodic ships with a SecurityServiceProvider that registers all authentication-related services in the DI container: AuthConfig, JwtValidator, ApiAuthenticationMiddleware, WebAuthenticationMiddleware, and more.
Register it in your public/index.php before defining routes:
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use Melodic\Core\Application;
use Melodic\Http\Middleware\JsonBodyParserMiddleware;
use Melodic\Security\SecurityServiceProvider;
$app = new Application(dirname(__DIR__));
$app->loadConfig('config/config.json');
$app->addMiddleware(new JsonBodyParserMiddleware());
// Register security services
$app->register(new SecurityServiceProvider());
// ... services, routes, and $app->run() follow
The register() method calls the provider's register() to bind services, then later calls boot() when the application runs.
Step 2: Configure Authentication
Add an auth section to your config/config.json. This example configures a local authentication provider with a symmetric signing key:
{
"app": {
"name": "My App",
"debug": true
},
"auth": {
"apiAuthEnabled": true,
"webAuthEnabled": true,
"cookieName": "auth_token",
"loginPath": "/auth/login",
"local": {
"signingKey": "your-secret-key-at-least-32-characters-long",
"algorithm": "HS256",
"issuer": "my-app",
"audience": "my-app",
"tokenLifetime": 3600
},
"providers": {
"local": {
"type": "local",
"name": "local",
"clientId": "my-app"
}
}
}
}
Security: In production, use a strong random signing key and store it outside your configuration file (for example, in an environment variable). The key shown here is for demonstration only.
Configuration options:
| Key | Purpose |
|---|---|
apiAuthEnabled | When true, API middleware rejects requests without a valid bearer token |
webAuthEnabled | When true, web middleware redirects unauthenticated users to the login page |
cookieName | Name of the cookie that stores the JWT for web authentication |
loginPath | Where unauthenticated web users are redirected |
local.signingKey | Symmetric key used to sign and verify JWT tokens |
local.tokenLifetime | Token expiry in seconds |
Step 3: Implement a LocalAuthenticator
The local auth provider needs a LocalAuthenticatorInterface implementation to validate credentials. This is where you check usernames and passwords against your database.
Create src/Security/AppAuthenticator.php:
<?php
declare(strict_types=1);
namespace App\Security;
use Melodic\Security\AuthResult;
use Melodic\Security\LocalAuthenticatorInterface;
class AppAuthenticator implements LocalAuthenticatorInterface
{
public function authenticate(string $username, string $password): AuthResult
{
// In a real app, look up the user in the database and verify the password hash.
// This example uses hardcoded credentials for demonstration.
$users = [
'admin' => [
'password' => 'secret',
'email' => 'admin@example.com',
'entitlements' => ['admin', 'posts:write'],
],
'reader' => [
'password' => 'secret',
'email' => 'reader@example.com',
'entitlements' => ['posts:read'],
],
];
if (!isset($users[$username]) || $users[$username]['password'] !== $password) {
return AuthResult::failure('Invalid username or password.');
}
$user = $users[$username];
return AuthResult::success([
'sub' => $username,
'username' => $username,
'email' => $user['email'],
'entitlements' => $user['entitlements'],
]);
}
}
Register the authenticator in your DI container (in public/index.php, before registering the SecurityServiceProvider):
use App\Security\AppAuthenticator;
use Melodic\Security\LocalAuthenticatorInterface;
$app->services(function ($container) {
$container->bind(LocalAuthenticatorInterface::class, AppAuthenticator::class);
});
// Register security AFTER the authenticator binding is in place
$app->register(new SecurityServiceProvider());
Tip: The SecurityServiceProvider resolves LocalAuthenticatorInterface from the container when building the local auth provider. Register your binding first so it is available.
Step 4: Protect API Routes
Use ApiAuthenticationMiddleware on your API route group. The middleware reads the Authorization: Bearer <token> header, validates the JWT, and attaches a UserContext to the request.
use Melodic\Security\ApiAuthenticationMiddleware;
$app->routes(function ($router) {
// Public routes (no authentication)
$router->get('/', HomeController::class, 'index');
// Protected API routes
$router->group('/api', function ($router) {
$router->apiResource('/tasks', TaskApiController::class);
}, middleware: [ApiAuthenticationMiddleware::class]);
});
When a request hits /api/tasks without a valid token, the middleware short-circuits the pipeline and returns:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{"error": "Authentication required."}
Step 5: Protect Web Routes
Use WebAuthenticationMiddleware for browser-facing routes. Instead of returning a 401, it redirects unauthenticated users to the configured login page.
use Melodic\Security\WebAuthenticationMiddleware;
$app->routes(function ($router) {
// Public pages
$router->get('/', HomeController::class, 'index');
// Protected pages (redirect to login if not authenticated)
$router->group('/dashboard', function ($router) {
$router->get('', DashboardController::class, 'index');
$router->get('/settings', DashboardController::class, 'settings');
}, middleware: [WebAuthenticationMiddleware::class]);
});
The web middleware:
- Reads the JWT from the cookie specified by
auth.cookieName - If the token is valid, creates a
UserContextand continues - If the token is missing or invalid, saves the current URL and redirects to
auth.loginPath
Step 6: Access UserContext in Controllers
Both ApiController and MvcController provide a getUserContext() method that retrieves the UserContext from the request attributes.
In an API Controller
<?php
declare(strict_types=1);
namespace App\Controllers;
use Melodic\Controller\Controller;
use Melodic\Http\Response;
class TaskApiController extends Controller
{
public function index(): Response
{
$userContext = $this->request->getAttribute('userContext');
// Access user information
$username = $userContext->getUsername();
$userId = $userContext->getUser()->id;
// Return user-specific data
return $this->json([
'user' => $username,
'tasks' => [], // ... fetch tasks for this user
]);
}
}
In an MVC Controller
<?php
declare(strict_types=1);
namespace App\Controllers;
use Melodic\Controller\MvcController;
use Melodic\Http\Response;
use Melodic\View\ViewEngine;
class DashboardController extends MvcController
{
public function __construct(ViewEngine $viewEngine)
{
parent::__construct($viewEngine);
}
public function index(): Response
{
$userContext = $this->getUserContext();
$this->viewBag->title = 'Dashboard';
$this->viewBag->username = $userContext->getUsername();
$this->setLayout('layouts/main');
return $this->view('dashboard/index');
}
}
The UserContext object provides these methods:
| Method | Returns |
|---|---|
isAuthenticated() | bool — true if a valid user is present |
getUser() | User|null — the authenticated user object |
getUsername() | string|null — the user's username |
hasEntitlement(string) | bool — check a single entitlement |
hasAnyEntitlement(string ...) | bool — check if user has any of the listed entitlements |
getProvider() | string|null — which auth provider authenticated the user |
getClaim(string, mixed) | mixed — get a raw JWT claim value |
Step 7: Check Entitlements
Entitlements are permission strings included in the JWT claims. Use them to implement fine-grained access control in your controllers.
public function destroy(int $id): Response
{
$userContext = $this->request->getAttribute('userContext');
// Only admins can delete tasks
if (!$userContext->hasEntitlement('admin')) {
return $this->forbidden(['error' => 'Admin access required']);
}
$this->taskService->delete($id);
return $this->noContent();
}
You can also check for multiple entitlements:
// User needs at least one of these entitlements
if (!$userContext->hasAnyEntitlement('admin', 'posts:write')) {
return $this->forbidden(['error' => 'Insufficient permissions']);
}
Entitlements vs. Roles: Melodic uses entitlements (fine-grained permissions) rather than roles. You can model roles as groups of entitlements in your authenticator by assigning the appropriate strings to each user.
Step 8: Show User Info in Templates
Pass the UserContext to your views through ViewBag or template data to display user information and conditionally show content.
In your controller:
public function index(): Response
{
$userContext = $this->getUserContext();
$this->viewBag->title = 'Dashboard';
$this->viewBag->user = $userContext;
$this->setLayout('layouts/main');
return $this->view('dashboard/index');
}
In your template (views/dashboard/index.phtml):
<h1>Welcome, <?= htmlspecialchars($viewBag->user->getUsername()) ?></h1>
<?php if ($viewBag->user->hasEntitlement('admin')): ?>
<div class="admin-panel">
<h2>Admin Panel</h2>
<p>You have administrator access.</p>
<a href="/dashboard/settings">Site Settings</a>
</div>
<?php endif; ?>
<p>Logged in via: <?= htmlspecialchars($viewBag->user->getProvider() ?? 'unknown') ?></p>
<p>Email: <?= htmlspecialchars($viewBag->user->getUser()->email ?? '') ?></p>
In the layout, you can show a login/logout link based on authentication state:
<nav>
<a href="/">Home</a>
<?php if (isset($viewBag->user) && $viewBag->user->isAuthenticated()): ?>
<a href="/dashboard">Dashboard</a>
<span>Hi, <?= htmlspecialchars($viewBag->user->getUsername()) ?></span>
<?php else: ?>
<a href="/auth/login">Log In</a>
<?php endif; ?>
</nav>
Complete Bootstrap Example
Here is a full public/index.php that ties everything together:
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use App\Controllers\DashboardController;
use App\Controllers\HomeController;
use App\Controllers\TaskApiController;
use App\Security\AppAuthenticator;
use Melodic\Core\Application;
use Melodic\Data\DbContext;
use Melodic\Data\DbContextInterface;
use Melodic\Http\Middleware\JsonBodyParserMiddleware;
use Melodic\Security\ApiAuthenticationMiddleware;
use Melodic\Security\LocalAuthenticatorInterface;
use Melodic\Security\SecurityServiceProvider;
use Melodic\Security\WebAuthenticationMiddleware;
use Melodic\View\ViewEngine;
$app = new Application(dirname(__DIR__));
$app->loadConfig('config/config.json');
$app->addMiddleware(new JsonBodyParserMiddleware());
// Register services
$app->services(function ($container) use ($app) {
$container->singleton(DbContextInterface::class, function () use ($app) {
$pdo = new PDO($app->config('database.dsn'));
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
return new DbContext($pdo);
});
$container->singleton(ViewEngine::class, function () use ($app) {
return new ViewEngine($app->getBasePath() . '/views');
});
$container->bind(LocalAuthenticatorInterface::class, AppAuthenticator::class);
});
// Register security provider (after LocalAuthenticatorInterface is bound)
$app->register(new SecurityServiceProvider());
// Routes
$app->routes(function ($router) {
// Public
$router->get('/', HomeController::class, 'index');
// Protected API
$router->group('/api', function ($router) {
$router->apiResource('/tasks', TaskApiController::class);
}, middleware: [ApiAuthenticationMiddleware::class]);
// Protected web pages
$router->group('/dashboard', function ($router) {
$router->get('', DashboardController::class, 'index');
$router->get('/settings', DashboardController::class, 'settings');
}, middleware: [WebAuthenticationMiddleware::class]);
});
$app->run();
Testing Authentication with curl
To test the API authentication, you need a valid JWT. If you are using the local provider, the auth callback endpoint issues tokens after successful login. You can also generate a token manually for testing:
# Request without a token (expect 401)
curl -v http://localhost:8080/api/tasks
# Request with a valid bearer token
curl http://localhost:8080/api/tasks \
-H "Authorization: Bearer <your-jwt-token>"
Next steps: Explore the Security documentation for OIDC and OAuth2 provider configuration, or learn to build custom middleware for additional request processing.