Chapter 8: Authentication#
1. Locking the Door#
Every endpoint built so far is public. Anyone with the URL can read, create, update, delete. That works for a tutorial. A real application needs to know who is making a request and whether they are allowed.
This chapter covers Tina4's authentication system: JWT tokens, password hashing, middleware-based route protection, CSRF tokens for forms, and session management.
2. JWT Tokens#
Tina4 uses JSON Web Tokens for authentication. A JWT is a signed string containing a payload -- user ID, role, expiry. The server creates it at login. The client sends it with every request. The server verifies it without touching the database.
Generating a Token#
<?phpuse Tina4\Auth;โ$payload = [ "user_id" => 42, "email" => "alice@example.com", "role" => "admin"];โ$token = Auth::getToken($payload, $secret);getToken() signs the payload with HS256 (HMAC-SHA256) using the provided secret. The $secret parameter is required. Returns a JWT string:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjo0MiwiZW1haWwiOiJhbGljZUBleGFtcGxlLmNvbSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxMTExMjYwMCwiZXhwIjoxNzExMTE2MjAwfQ.abc123signatureThree parts separated by dots: header, payload, signature. The signature ensures the token has not been tampered with.
Token Expiry#
Tokens expire after 60 minutes by default. Configure in .env:
TINA4_TOKEN_LIMIT=60Value in minutes (default: 60):
| Value | Duration |
|---|---|
15 | 15 minutes |
60 | 1 hour (default) |
1440 | 24 hours |
10080 | 7 days |
Validating a Token#
$payload = Auth::validToken($token, $secret);// Returns the payload array if valid, null if invalid or expiredvalidToken() returns the decoded payload on success, not a boolean. This lets you validate and read the token in one step. Returns null if the token is invalid or expired.
Reading the Payload#
$payload = Auth::getPayload($token);Returns the decoded payload without validation -- it just decodes the token. Note that getPayload() takes only the token string -- no secret is needed because it does not verify the signature:
[ "user_id" => 42, "email" => "alice@example.com", "role" => "admin", "iat" => 1711112600, // issued at (Unix timestamp) "exp" => 1711116200 // expires at (Unix timestamp)]If the token cannot be decoded: null.
Important:
getPayload()does not verify the signature or check expiry. UsevalidToken()when you need to confirm the token is trustworthy.
The Secret Key and Algorithm#
Tina4 PHP uses HS256 (HMAC-SHA256) for JWT signing. It uses only the standard library -- zero external dependencies.
Set the secret key in .env:
TINA4_SECRET=my-super-secret-key-at-least-32-charsThe $secret parameter is required on getToken() and validToken(). Pass it explicitly -- there is no automatic fallback. Read it from your .env in your route handler:
$secret = $_ENV["TINA4_SECRET"] ?? getenv("TINA4_SECRET");getPayload() does not take a secret at all -- it decodes without verifying.
Guard this key. Anyone who has it can forge tokens.
3. Password Hashing#
Passwords in plain text are a breach waiting to happen. Tina4 provides two functions for secure password handling.
Hashing a Password#
use Tina4\Auth;โ$hash = Auth::hashPassword("my-secure-password");// Returns: "$2y$10$abc123...long-hash-string..."Uses PBKDF2 from the standard library -- no external dependencies. Each hash includes a random salt. Hashing the same password twice produces different results.
Checking a Password#
$isCorrect = Auth::checkPassword("my-secure-password", $storedHash);// true if the password matchesRegistration Example#
<?phpuse Tina4\Router;use Tina4\Auth;use Tina4\Database;โRouter::post("/api/register", function ($request, $response) { $body = $request->body;โ if (empty($body["name"]) || empty($body["email"]) || empty($body["password"])) { return $response->json(["error" => "Name, email, and password are required"], 400); }โ if (strlen($body["password"]) < 8) { return $response->json(["error" => "Password must be at least 8 characters"], 400); }โ $db = Database::getConnection();โ $existing = $db->fetchOne("SELECT id FROM users WHERE email = :email", ["email" => $body["email"]]); if ($existing !== null) { return $response->json(["error" => "Email already registered"], 409); }โ $passwordHash = Auth::hashPassword($body["password"]);โ $db->execute( "INSERT INTO users (name, email, password_hash) VALUES (:name, :email, :hash)", [ "name" => $body["name"], "email" => $body["email"], "hash" => $passwordHash ] );โ $user = $db->fetchOne("SELECT id, name, email FROM users WHERE id = last_insert_rowid()");โ return $response->json([ "message" => "Registration successful", "user" => $user ], 201);});curl -X POST http://localhost:7145/api/register \ -H "Content-Type: application/json" \ -d '{"name": "Alice", "email": "alice@example.com", "password": "securePass123"}'{ "message": "Registration successful", "user": {"id": 1, "name": "Alice", "email": "alice@example.com"}}4. The Login Flow#
Client sends credentials. Server validates them. Server returns a JWT.
<?phpuse Tina4\Router;use Tina4\Auth;use Tina4\Database;โ/** * @noauth */Router::post("/api/login", function ($request, $response) { $body = $request->body;โ if (empty($body["email"]) || empty($body["password"])) { return $response->json(["error" => "Email and password are required"], 400); }โ $db = Database::getConnection();โ $user = $db->fetchOne( "SELECT id, name, email, password_hash FROM users WHERE email = :email", ["email" => $body["email"]] );โ if ($user === null) { return $response->json(["error" => "Invalid email or password"], 401); }โ if (!Auth::checkPassword($body["password"], $user["password_hash"])) { return $response->json(["error" => "Invalid email or password"], 401); }โ $token = Auth::getToken([ "user_id" => $user["id"], "email" => $user["email"], "name" => $user["name"] ]);โ return $response->json([ "message" => "Login successful", "token" => $token, "user" => [ "id" => $user["id"], "name" => $user["name"], "email" => $user["email"] ] ]);});The @noauth annotation is critical. The login endpoint must be public. You cannot require a token to get a token.
curl -X POST http://localhost:7145/api/login \ -H "Content-Type: application/json" \ -d '{"email": "alice@example.com", "password": "securePass123"}'{ "message": "Login successful", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "user": { "id": 1, "name": "Alice", "email": "alice@example.com" }}The client stores the token and sends it with subsequent requests.
5. Using Tokens in Requests#
The token travels in the Authorization header with the Bearer prefix:
curl http://localhost:7145/api/profile \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."6. Protecting Routes#
Auth Middleware#
A reusable gate:
<?phpuse Tina4\Auth;โfunction authMiddleware($request, $response, $next) { $authHeader = $request->header("Authorization") ?? "";โ if (empty($authHeader) || !str_starts_with($authHeader, "Bearer ")) { return $response->json(["error" => "Authorization header required"], 401); }โ $token = substr($authHeader, 7); // Remove "Bearer " prefixโ $payload = Auth::validToken($token); if ($payload === null) { return $response->json(["error" => "Invalid or expired token"], 401); }โ $request->user = $payload; // Attach user data to the requestโ return $next($request, $response);}Applying Middleware to Routes#
<?phpuse Tina4\Router;โRouter::get("/api/profile", function ($request, $response) { return $response->json([ "user_id" => $request->user["user_id"], "email" => $request->user["email"], "name" => $request->user["name"] ]);}, "authMiddleware");# Without token -- 401curl http://localhost:7145/api/profile{"error":"Authorization header required"}# With valid token -- 200curl http://localhost:7145/api/profile \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."{ "user_id": 1, "email": "alice@example.com", "name": "Alice"}Applying Middleware to a Group#
Router::group("/api/admin", function () {โ Router::get("/stats", function ($request, $response) { return $response->json(["active_users" => 42]); });โ Router::get("/logs", function ($request, $response) { return $response->json(["logs" => []]); });โ}, "authMiddleware");Every route in the group requires a valid token.
7. @noauth and @secured Decorators#
Two decorators for route-level authentication control. Introduced in Chapter 2. Here they are in context.
@noauth -- Skip Authentication#
Public endpoints that bypass global or group-level auth:
/** * @noauth */Router::get("/api/public/health", function ($request, $response) { return $response->json(["status" => "ok"]);});โ/** * @noauth */Router::post("/api/login", function ($request, $response) { // Login logic});โ/** * @noauth */Router::post("/api/register", function ($request, $response) { // Registration logic});@secured -- Require Authentication for GET Routes#
POST, PUT, PATCH, DELETE are secured by default. GET is public. Use @secured to protect a GET route:
/** * @secured */Router::get("/api/me", function ($request, $response) { return $response->json($request->user);});Role-Based Authorization#
Combine auth middleware with role checks:
<?phpuse Tina4\Auth;โfunction requireRole($role) { return function ($request, $response, $next) use ($role) { $authHeader = $request->header("Authorization") ?? ""; if (empty($authHeader) || !str_starts_with($authHeader, "Bearer ")) { return $response->json(["error" => "Authorization required"], 401); }โ $token = substr($authHeader, 7); $payload = Auth::validToken($token); if ($payload === null) { return $response->json(["error" => "Invalid or expired token"], 401); }โ if (($payload["role"] ?? "") !== $role) { return $response->json(["error" => "Forbidden. Required role: " . $role], 403); }โ $request->user = $payload; return $next($request, $response); };}requireRole() returns a closure. Register the returned function as middleware:
$adminOnly = requireRole("admin");โRouter::delete("/api/users/{id:int}", function ($request, $response) { return $response->json(["deleted" => true]);}, $adminOnly);8. CSRF Protection#
For traditional form-based applications (not SPAs), Tina4 provides CSRF protection.
Generating a Token#
Include the CSRF token in every form:
<form method="POST" action="/profile/update"> {{ form_token() }} <div class="form-group"> <label for="name">Name</label> <input type="text" name="name" id="name" value="{{ user.name }}"> </div> <button type="submit">Update Profile</button></form>{{ form_token() }} renders a hidden input:
<input type="hidden" name="formToken" value="abc123randomtoken456">Validating the Token#
Register CsrfMiddleware and it validates the formToken on every state-changing request automatically, no manual check in the handler:
<?phpuse Tina4\Router;use Tina4\Middleware\CsrfMiddleware;โ// Enable globally (or set TINA4_CSRF=true in .env)Router::use(CsrfMiddleware::class);โRouter::post("/profile/update", function ($request, $response) { // CsrfMiddleware already rejected the request with a 403 if the // formToken was missing or invalid - just process the form. return $response->redirect("/profile");});The middleware reads the token from request->body["formToken"] (or the X-Form-Token header), validates it with Auth::validToken(), and returns a 403 CSRF_INVALID error if it is missing or invalid. The token is tied to the user's session. A malicious site cannot forge it.
When to Use CSRF Tokens#
Use them for:
- HTML forms submitted by browsers
- POST/PUT/DELETE from server-rendered pages
Skip them for:
- API endpoints using JWT (the Bearer token proves intent)
- SPAs using
fetch()with custom headers (theAuthorizationheader cannot be set by cross-origin forms)
9. Sessions#
Server-side sessions store per-user state between requests. Use JWTs for API authentication. Use sessions for stateful web pages.
Session Configuration#
Set the backend in .env:
# File-based sessions (default)TINA4_SESSION_BACKEND=fileโ# RedisTINA4_SESSION_BACKEND=redisTINA4_SESSION_REDIS_HOST=localhostTINA4_SESSION_REDIS_PORT=6379โ# MongoDBTINA4_SESSION_BACKEND=mongodbTINA4_SESSION_REDIS_HOST=localhostTINA4_SESSION_REDIS_PORT=27017โ# ValkeyTINA4_SESSION_BACKEND=valkeyTINA4_SESSION_REDIS_HOST=localhostTINA4_SESSION_REDIS_PORT=6379File sessions work out of the box. Redis or Valkey for multi-server production deployments where sessions must be shared across instances.
Using Sessions#
Access session data through $request->session:
<?phpuse Tina4\Router;โRouter::post("/login-form", function ($request, $response) { // After validating credentials... $request->session["user_id"] = 42; $request->session["user_name"] = "Alice"; $request->session["logged_in"] = true;โ return $response->redirect("/dashboard");});โRouter::get("/dashboard", function ($request, $response) { if (empty($request->session["logged_in"])) { return $response->redirect("/login"); }โ return $response->render("dashboard.html", [ "user_name" => $request->session["user_name"] ]);});โRouter::post("/logout", function ($request, $response) { $request->session = [];โ return $response->redirect("/login");});Session Options#
TINA4_SESSION_TTL=3600 # Expires after 1 hour of inactivityTINA4_SESSION_NAME=tina4_session # Cookie name for the session ID10. Exercise: Build Login, Register, and Profile#
A complete authentication system. Registration, login, profile viewing, password changing.
Requirements#
- Create a
userstable migration:id,name,email(unique),password_hash,role(default "user"),created_at - Build these endpoints:
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /api/register | @noauth | Create account. Validate name, email, password (min 8 chars). |
POST | /api/login | @noauth | Login. Return JWT. |
GET | /api/profile | secured | Get profile from token. |
PUT | /api/profile | secured | Update name and email. |
PUT | /api/profile/password | secured | Change password. Require current password. |
- Create auth middleware that extracts the user from the JWT and attaches it to
$request->user.
Test with:#
# Registercurl -X POST http://localhost:7145/api/register \ -H "Content-Type: application/json" \ -d '{"name": "Alice", "email": "alice@example.com", "password": "securePass123"}'โ# Logincurl -X POST http://localhost:7145/api/login \ -H "Content-Type: application/json" \ -d '{"email": "alice@example.com", "password": "securePass123"}'โ# Save the token, then:โ# Get profilecurl http://localhost:7145/api/profile \ -H "Authorization: Bearer YOUR_TOKEN_HERE"โ# Update profilecurl -X PUT http://localhost:7145/api/profile \ -H "Authorization: Bearer YOUR_TOKEN_HERE" \ -H "Content-Type: application/json" \ -d '{"name": "Alice Smith"}'โ# Change passwordcurl -X PUT http://localhost:7145/api/profile/password \ -H "Authorization: Bearer YOUR_TOKEN_HERE" \ -H "Content-Type: application/json" \ -d '{"current_password": "securePass123", "new_password": "evenMoreSecure456"}'โ# No token (should fail)curl http://localhost:7145/api/profile11. Solution#
Migration#
Create migrations/20260322160000_create_auth_users_table.sql:
-- UPCREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', created_at TEXT DEFAULT CURRENT_TIMESTAMP);โ-- DOWNDROP TABLE IF EXISTS users;tina4 migrateMiddleware#
Create src/routes/middleware.php:
<?phpuse Tina4\Auth;โfunction authMiddleware($request, $response, $next) { $authHeader = $request->header("Authorization") ?? "";โ if (empty($authHeader) || !str_starts_with($authHeader, "Bearer ")) { return $response->json(["error" => "Authorization required. Send: Authorization: Bearer <token>"], 401); }โ $token = substr($authHeader, 7);โ $payload = Auth::validToken($token); if ($payload === null) { return $response->json(["error" => "Invalid or expired token. Please login again."], 401); }โ $request->user = $payload;โ return $next($request, $response);}Routes#
Create src/routes/auth.php:
<?phpuse Tina4\Router;use Tina4\Auth;use Tina4\Database;โ/** * @noauth */Router::post("/api/register", function ($request, $response) { $body = $request->body;โ $errors = []; if (empty($body["name"])) $errors[] = "Name is required"; if (empty($body["email"])) $errors[] = "Email is required"; if (empty($body["password"])) { $errors[] = "Password is required"; } elseif (strlen($body["password"]) < 8) { $errors[] = "Password must be at least 8 characters"; }โ if (!empty($errors)) { return $response->json(["errors" => $errors], 400); }โ $db = Database::getConnection();โ $existing = $db->fetchOne("SELECT id FROM users WHERE email = :email", ["email" => $body["email"]]); if ($existing !== null) { return $response->json(["error" => "Email already registered"], 409); }โ $hash = Auth::hashPassword($body["password"]);โ $db->execute( "INSERT INTO users (name, email, password_hash) VALUES (:name, :email, :hash)", ["name" => $body["name"], "email" => $body["email"], "hash" => $hash] );โ $user = $db->fetchOne("SELECT id, name, email, role, created_at FROM users WHERE id = last_insert_rowid()");โ return $response->json(["message" => "Registration successful", "user" => $user], 201);});โ/** * @noauth */Router::post("/api/login", function ($request, $response) { $body = $request->body;โ if (empty($body["email"]) || empty($body["password"])) { return $response->json(["error" => "Email and password are required"], 400); }โ $db = Database::getConnection();โ $user = $db->fetchOne( "SELECT id, name, email, password_hash, role FROM users WHERE email = :email", ["email" => $body["email"]] );โ if ($user === null || !Auth::checkPassword($body["password"], $user["password_hash"])) { return $response->json(["error" => "Invalid email or password"], 401); }โ $token = Auth::getToken([ "user_id" => $user["id"], "email" => $user["email"], "name" => $user["name"], "role" => $user["role"] ]);โ return $response->json([ "message" => "Login successful", "token" => $token, "user" => ["id" => $user["id"], "name" => $user["name"], "email" => $user["email"], "role" => $user["role"]] ]);});โRouter::get("/api/profile", function ($request, $response) { $db = Database::getConnection();โ $user = $db->fetchOne( "SELECT id, name, email, role, created_at FROM users WHERE id = :id", ["id" => $request->user["user_id"]] );โ if ($user === null) { return $response->json(["error" => "User not found"], 404); }โ return $response->json($user);}, "authMiddleware");โRouter::put("/api/profile", function ($request, $response) { $db = Database::getConnection(); $body = $request->body; $userId = $request->user["user_id"];โ if (!empty($body["email"])) { $existing = $db->fetchOne( "SELECT id FROM users WHERE email = :email AND id != :id", ["email" => $body["email"], "id" => $userId] ); if ($existing !== null) { return $response->json(["error" => "Email already in use by another account"], 409); } }โ $current = $db->fetchOne("SELECT * FROM users WHERE id = :id", ["id" => $userId]);โ $db->execute( "UPDATE users SET name = :name, email = :email WHERE id = :id", ["name" => $body["name"] ?? $current["name"], "email" => $body["email"] ?? $current["email"], "id" => $userId] );โ $updated = $db->fetchOne("SELECT id, name, email, role, created_at FROM users WHERE id = :id", ["id" => $userId]);โ return $response->json(["message" => "Profile updated", "user" => $updated]);}, "authMiddleware");โRouter::put("/api/profile/password", function ($request, $response) { $db = Database::getConnection(); $body = $request->body; $userId = $request->user["user_id"];โ if (empty($body["current_password"]) || empty($body["new_password"])) { return $response->json(["error" => "Current password and new password are required"], 400); }โ if (strlen($body["new_password"]) < 8) { return $response->json(["error" => "New password must be at least 8 characters"], 400); }โ $user = $db->fetchOne("SELECT password_hash FROM users WHERE id = :id", ["id" => $userId]);โ if (!Auth::checkPassword($body["current_password"], $user["password_hash"])) { return $response->json(["error" => "Current password is incorrect"], 401); }โ $newHash = Auth::hashPassword($body["new_password"]);โ $db->execute("UPDATE users SET password_hash = :hash WHERE id = :id", ["hash" => $newHash, "id" => $userId]);โ return $response->json(["message" => "Password changed successfully"]);}, "authMiddleware");Expected output for register: 201 Created with user data.
Expected output for login: 200 OK with token and user data.
Expected output for profile without token: 401 Unauthorized.
Expected output for profile with token: 200 OK with user data.
Expected output for wrong current password: 401 Unauthorized.
Expected output for successful password change: {"message":"Password changed successfully"}.
12. Gotchas#
1. Token Expiry Confusion#
Problem: Tokens that worked yesterday return 401 today.
Cause: Default lifetime is 60 minutes (TINA4_TOKEN_LIMIT=60). After that, the token is invalid.
Fix: Issue a new token at login. For long-lived sessions, use refresh tokens: a short-lived access token (15 minutes) paired with a long-lived refresh token (7 days).
2. Secret Key Management#
Problem: Tokens from one server fail on another. Or tokens stop working after deployment.
Cause: Each server generated its own secrets/jwt.key. Or the key was regenerated during deployment.
Fix: Set TINA4_SECRET in .env and use the same value across all servers. Store it in your secrets manager. Not in version control. Key change invalidates all tokens. Users must log in again.
3. CORS with Authentication#
Problem: Frontend requests with Authorization header fail with CORS error, even with CORS_ORIGINS=*.
Cause: The browser sends a preflight OPTIONS request. The server must respond with CORS headers including Access-Control-Allow-Headers: Authorization.
Fix: Tina4 handles preflight requests. Verify CORS_ORIGINS is set. Check that middleware is not overriding CORS headers.
4. Storing Tokens in localStorage#
Problem: Token stolen via XSS because it lived in localStorage.
Cause: Any JavaScript on the page reads localStorage, including injected scripts.
Fix: Use httpOnly cookies when possible -- invisible to JavaScript. For SPAs that must use localStorage, enforce strict Content Security Policy headers. Sanitize all input.
5. Forgetting @noauth on Login#
Problem: Login endpoint returns 401. Cannot log in.
Cause: Global auth middleware blocks the login endpoint. You need a token to get a token. A catch-22.
Fix: Add @noauth to login and register routes.
6. Password Hash Column Too Short#
Problem: Registration fails with a database error about truncation.
Cause: PBKDF2 hashes can be long. VARCHAR(50) truncates them.
Fix: Use TEXT for the password hash column. Or at minimum VARCHAR(255).
7. Token in URL Query Parameters#
Problem: Tokens in URLs like /api/profile?token=eyJ... leak through browser history, server logs, and Referer headers.
Fix: Always send tokens in the Authorization header. The only exception: WebSocket connections where the upgrade request cannot carry custom headers. Use a short-lived token for that case.