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. Since 3.0 an HMAC signing key shorter than 32 characters is rejected at startup.
Local development (3.0): The auth and session cookies now default to Secure, so they are not sent over plain HTTP and login will appear to fail on http://localhost. Add "cookieSecure": false under both auth and session in your dev-only config (e.g. config/config.dev.json) while developing.
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, <?= $this->e($viewBag->user->getUsername()) ?></span>
<!-- Logout is a CSRF-protected POST -->
<form method="post" action="/auth/logout" style="display:inline">
<input type="hidden" name="csrf_token" value="<?= $this->e($viewBag->csrfToken) ?>">
<button type="submit">Log Out</button>
</form>
<?php else: ?>
<a href="/auth/login">Log In</a>
<?php endif; ?>
</nav>
Register the matching auth routes so AuthCallbackMiddleware can handle login and logout (note logout is a POST):
$router->group('/auth', function ($router) {
$router->get('/login', HomeController::class, 'index');
$router->post('/callback/{provider}', HomeController::class, 'index');
$router->post('/logout', HomeController::class, 'index');
}, middleware: [AuthCallbackMiddleware::class]);
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.