Skip to content

How-to: RBAC + JWT Authentication ​

FT reference: FT279 (NENE2-FT/rbaclog) — Role-Based Access Control with JWT: Argon2id password hashing with timing-attack protection, role claim in JWT, 401 vs 403 distinction, BearerTokenMiddleware with manual fallback, 14 tests / 48 assertions PASS.

VULN assessment: V-01 through V-10 included at the end of this document.

This guide shows how to build a role-based access control (RBAC) system using JWT tokens with NENE2.

Features ​

  • Email + password login (Argon2id hashing)
  • Role claim embedded in JWT (user / admin)
  • Public, authenticated, and admin-only endpoints
  • BearerTokenMiddleware with per-handler fallback
  • Correct 401 Unauthorized vs 403 Forbidden semantics

Schema ​

sql
CREATE TABLE IF NOT EXISTS users (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    email         TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    role          TEXT NOT NULL DEFAULT 'user',
    created_at    TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS posts (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    title      TEXT NOT NULL,
    body       TEXT NOT NULL,
    author_id  INTEGER NOT NULL,
    created_at TEXT NOT NULL
);

Endpoints ​

MethodPathAuthDescription
POST/auth/loginNoneLogin, receive JWT
GET/postsNoneList all posts (public)
POST/postsUser or AdminCreate a post
DELETE/posts/{id}Admin onlyDelete a post

Login with Timing-Attack Protection ​

The dummy hash trick ensures login always takes the same time whether or not the email exists:

php
$user = $this->users->findByEmail(trim($body['email']));

$dummyHash   = '$argon2id$v=19$m=65536,t=4,p=1$dummysaltdummysaltdummysalt$dummyhashvaluedummyhashvaluedummyh';
$hashToCheck = $user !== null ? $user->passwordHash : $dummyHash;

if (!password_verify($body['password'], $hashToCheck) || $user === null) {
    return $this->problems->create($request, 'invalid-credentials', 'Invalid Credentials', 401, '...');
}

Without the dummy hash, a timing attack can detect valid email addresses by measuring response time — the hash computation is skipped for unknown emails.

Role Claim in JWT ​

Role is stored in the JWT payload to avoid a DB round-trip on every request:

php
$token = $this->issuer->issue([
    'sub'   => $user->id,
    'email' => $user->email,
    'role'  => $user->role->value,   // Role::User → 'user', Role::Admin → 'admin'
    'iat'   => $now,
    'exp'   => $now + self::TOKEN_TTL_SECONDS,
]);

Role Check with Enum ​

php
private function requireRole(ServerRequestInterface $request, Role $required): array|ResponseInterface
{
    $claims = $this->requireAuth($request);
    if ($claims instanceof ResponseInterface) {
        return $claims;
    }

    $actualRole = Role::tryFrom((string) ($claims['role'] ?? ''));

    if ($actualRole !== $required) {
        return $this->problems->create(
            $request, 'forbidden', 'Forbidden', 403,
            "This action requires the '{$required->value}' role."
        );
    }

    return $claims;
}

Role::tryFrom() safely maps the string claim back to the enum — invalid role strings become null, which fails the check.

401 vs 403 Distinction ​

StatusMeaningWhen
401 UnauthorizedNot authenticatedNo token, invalid token, expired token
403 ForbiddenAuthenticated but insufficient roleValid token, wrong role

This distinction matters for clients: a 401 should prompt a re-login; a 403 should show an "access denied" message.

BearerTokenMiddleware with Fallback ​

Some paths serve both public and protected methods (e.g., GET /posts is public, POST /posts is authenticated). The middleware excludes the path entirely, and handlers that require auth call requireAuth() manually:

php
$authMiddleware = new BearerTokenMiddleware(
    problemDetails: $problems,
    verifier: $this->verifier,
    excludedPaths: ['/auth/login', '/posts'],  // /posts needs per-method handling
);
php
private function requireAuth(ServerRequestInterface $request): array|ResponseInterface
{
    // Fast path: middleware already verified
    $claims = $request->getAttribute('nene2.auth.claims');
    if (is_array($claims)) {
        return $claims;
    }

    // Slow path: manual extraction for excluded paths
    $authorization = $request->getHeaderLine('Authorization');
    if ($authorization === '' || !str_starts_with($authorization, 'Bearer ')) {
        return $this->problems->create($request, 'unauthorized', 'Unauthorized', 401, '...');
    }

    try {
        return $this->verifier->verify(substr($authorization, 7));
    } catch (TokenVerificationException) {
        return $this->problems->create($request, 'unauthorized', 'Unauthorized', 401, '...');
    }
}

VULN Assessment — Vulnerability Diagnosis ​

V-01 — Role elevation via forged JWT claim 🛡️ SAFE ​

Threat: Attacker creates a JWT with "role": "admin" and signs it with a random secret. Defense: LocalBearerTokenVerifier validates HMAC-HS256 signature against the server secret. A mismatched secret causes TokenVerificationException → 401. Result: SAFE — signature verification prevents claim forgery.


V-02 — Timing attack via email enumeration on login 🛡️ SAFE ​

Threat: Attacker sends login requests for unknown vs known emails and measures response time to enumerate valid accounts. Defense: For unknown emails, password_verify() is called against a dummy Argon2id hash (same cost parameters). Both paths take ~200ms. Login failure message is identical for wrong email and wrong password. Result: SAFE — timing is equalized; error message is generic.


V-03 — Expired token accepted as valid 🛡️ SAFE ​

Threat: Attacker reuses a captured JWT after it expires. Defense: LocalBearerTokenVerifier checks exp claim against time(). Expired tokens throw TokenVerificationException → 401. Result: SAFE — exp check is enforced.


V-04 — Role downgrade by modifying JWT payload (without re-signing) 🛡️ SAFE ​

Threat: Attacker base64-decodes the JWT payload, changes "role": "user" to "role": "admin", re-encodes, and submits with original signature. Defense: JWT signature covers the header + payload. Modifying the payload invalidates the signature → TokenVerificationException → 401. Result: SAFE — payload tamper detected by HMAC.


V-05 — Admin endpoint accessible with user role 🛡️ SAFE ​

Threat: Attacker logs in as a user and attempts DELETE /posts/{id}. Defense: requireRole($request, Role::Admin) checks the JWT role claim. A user token has role: 'user' → Role::tryFrom('user') !== Role::Admin → 403. Result: SAFE — 403 Forbidden returned; user token cannot elevate to admin.


V-06 — Unauthenticated access to protected endpoint 🛡️ SAFE ​

Threat: Attacker sends POST /posts or DELETE /posts/{id} with no Authorization header. Defense: requireAuth() checks for Bearer prefix; absent header → 401 unauthorized. Result: SAFE — 401 Unauthorized returned.


V-07 — 401 vs 403 confusion (information leakage) 🛡️ SAFE ​

Threat: Incorrect 401/403 usage reveals whether a resource exists or whether the user is authenticated. Defense: System returns 401 for unauthenticated access (no/invalid token) and 403 for authenticated access with insufficient role. The distinction is semantically correct and does not reveal resource existence beyond the role requirement. Result: SAFE — 401/403 semantics are correct; tests test401MeansNotAuthenticated and test403MeansAuthenticatedButForbidden both pass.


V-08 — Invalid role string in JWT bypass 🛡️ SAFE ​

Threat: Attacker crafts a JWT (with valid secret, e.g., compromised secret scenario) and sets role to an unknown value like "superadmin". Defense: Role::tryFrom((string) ($claims['role'] ?? '')) returns null for unknown strings → null !== Role::Admin → 403. Result: SAFE — tryFrom() is null-safe; unknown roles are treated as insufficient.


V-09 — SQL injection via email field on login 🛡️ SAFE ​

Threat: Attacker sends {"email": "' OR '1'='1", "password": "anything"}. Defense: findByEmail() uses parameterized query (WHERE email = ?). The injected string is treated as a literal value, not SQL. Result: SAFE — parameterized queries prevent SQL injection.


V-10 — Password stored in plaintext 🛡️ SAFE ​

Threat: If the DB is breached, passwords are readable. Defense: password_hash($password, PASSWORD_ARGON2ID) with cost parameters m=65536,t=4,p=1. Only the Argon2id hash is stored; the plaintext password is never persisted. Result: SAFE — Argon2id is the current recommended algorithm (RFC 9106); PBKDF2/bcrypt/scrypt would also pass.


VULN Summary ​

IDThreatResult
V-01Role elevation via forged JWT🛡️ SAFE
V-02Timing attack via email enumeration🛡️ SAFE
V-03Expired token accepted🛡️ SAFE
V-04JWT payload tamper without re-signing🛡️ SAFE
V-05Admin endpoint with user role token🛡️ SAFE
V-06Unauthenticated access to protected endpoint🛡️ SAFE
V-07401 vs 403 confusion🛡️ SAFE
V-08Unknown role string bypass🛡️ SAFE
V-09SQL injection via email field🛡️ SAFE
V-10Password stored in plaintext🛡️ SAFE

10 SAFE, 0 EXPOSED Argon2id hashing, HMAC-signed JWT, Role::tryFrom() guard, and parameterized queries prevent all tested vulnerability vectors.


What NOT to do ​

Anti-patternRisk
Store role in DB and look up on every requestExtra DB query per request; role change requires token revocation logic
Use Role::from() instead of Role::tryFrom()Unknown role strings throw ValueError — 500 instead of 403
Return 403 for unauthenticated requestsMisleads clients — 403 should mean "authenticated but forbidden," not "not logged in"
Return 401 for wrong-role accessClient may attempt re-login instead of showing "access denied"
Skip dummy hash in loginTiming attack reveals valid email addresses
Store passwords as MD5/SHA1/plaintextBrute-force or rainbow table attacks expose all passwords in a DB breach
Embed permissions in JWT (not roles)Permission set changes require token reissuance; roles are stable, permissions change
Allow alg: none JWTAttacker can forge tokens by removing signature entirely
Use str_contains($role, 'admin') instead of enum check"not-admin" or "superadmin" might match unexpectedly

Released under the MIT License.