Tina4

Chapter 8: Authentication#

1. Locking the Door#

Every endpoint you have built is public. Anyone with the URL can read, create, update, and delete data. Fine for a tutorial. Reckless for production. A real application needs to know who is making a request and whether they have permission.

This chapter covers Tina4's authentication system. JWT tokens. Password hashing. Middleware-based route protection. CSRF tokens for forms. Session management.


2. JWT Tokens#

Tina4 uses JSON Web Tokens (JWT) for authentication. A JWT is a signed string containing a payload -- user ID, role, whatever you need. The server mints the token at login. The client sends it with every request. The server verifies it without touching the database.

Generating a Token#

ruby
payload = {  user_id: 42,  email: "alice@example.com",  role: "admin"}โ€‹token = Tina4::Auth.get_token(payload)

get_token signs the payload and returns a JWT string like:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjo0MiwiZW1haWwiOiJhbGljZUBleGFtcGxlLmNvbSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxMTExMjYwMCwiZXhwIjoxNzExMTE2MjAwfQ.abc123signature

The token has three parts separated by dots: header, payload, and signature. The signature ensures the token has not been tampered with.

Legacy aliases: Tina4::Auth.create_token still works as an alias for Tina4::Auth.get_token. Use the primary name in new code.

Token Expiry#

By default, tokens expire after 60 minutes. Configure the expiry when generating the token:

ruby
# Default: 60 minutestoken = Tina4::Auth.get_token(payload)โ€‹# Custom: 24 hours (1440 minutes)token = Tina4::Auth.get_token(payload, expires_in: 1440)โ€‹# Custom: 7 days (10080 minutes)token = Tina4::Auth.get_token(payload, expires_in: 10080)

The expires_in value is in minutes. Common settings:

ValueDuration
1515 minutes
601 hour (default)
144024 hours
100807 days

Validating a Token#

ruby
payload = Tina4::Auth.valid_token(token)# Returns the payload hash if valid, nil if invalid or expired

valid_token returns the decoded payload on success, not a boolean. This lets you validate and read the token in one step. Returns nil if the token is invalid or expired.

Legacy alias: Tina4::Auth.validate_token works the same way.

Reading the Payload#

ruby
payload = Tina4::Auth.get_payload(token)

Returns the decoded payload hash without validation -- it just decodes the token:

ruby
{  "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, get_payload returns nil.

Important: get_payload does not verify the signature or check expiry. Use valid_token when you need to confirm the token is trustworthy.

The Secret Key and Algorithm#

Tina4 Ruby supports two JWT algorithms, auto-detected based on your configuration:

  • HS256 (HMAC-SHA256) -- set TINA4_SECRET in .env. Uses the standard library. Zero dependencies.
  • RS256 (RSA) -- RSA keys are auto-generated in the .keys/ folder. Requires the jwt gem (included by default).
bash
# .env -- HS256 mode (recommended, simplest setup)TINA4_SECRET=my-super-secret-key-at-least-32-chars

If TINA4_SECRET is set and no RSA keys exist in .keys/, Tina4 uses HS256. If RSA keys exist in .keys/ instead, Tina4 uses RS256. If neither is configured, Tina4 auto-generates RSA keys in .keys/ on first run.

Keep this key secret. If someone gets it, they can forge tokens.


3. Password Hashing#

Plain text passwords are a security breach waiting to happen. Tina4 provides two methods for secure password handling:

Hashing a Password#

ruby
hash = Tina4::Auth.hash_password("my-secure-password")# Returns: "$2a$12$abc123...long-hash-string..."

This uses BCrypt (via the bcrypt gem, included by default). Each hash includes a random salt, so hashing the same password twice produces different results.

Checking a Password#

ruby
is_correct = Tina4::Auth.check_password("my-secure-password", stored_hash)# Returns true if the password matches the hash

Registration Example#

ruby
# @noauthTina4::Router.post("/api/register") do |request, response|  body = request.bodyโ€‹  # Validate input  if body["name"].nil? || body["email"].nil? || body["password"].nil?    return response.json({ error: "Name, email, and password are required" }, 400)  endโ€‹  if body["password"].length < 8    return response.json({ error: "Password must be at least 8 characters" }, 400)  endโ€‹  db = Tina4.databaseโ€‹  # Check if email already exists  existing = db.fetch_one("SELECT id FROM users WHERE email = ?", [body["email"]])  unless existing.nil?    return response.json({ error: "Email already registered" }, 409)  endโ€‹  # Hash the password  password_hash = Tina4::Auth.hash_password(body["password"])โ€‹  # Create the user  db.execute(    "INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?)",    [body["name"], body["email"], password_hash]  )โ€‹  user = db.fetch_one("SELECT id, name, email FROM users WHERE id = last_insert_rowid()")โ€‹  response.json({    message: "Registration successful",    user: user  }, 201)end
bash
curl -X POST http://localhost:7147/api/register \  -H "Content-Type: application/json" \  -d '{"name": "Alice", "email": "alice@example.com", "password": "securePass123"}'
json
{  "message": "Registration successful",  "user": {"id": 1, "name": "Alice", "email": "alice@example.com"}}

4. The Login Flow#

Here is the complete login flow: the client sends credentials, the server validates them, and returns a JWT token.

ruby
# @noauthTina4::Router.post("/api/login") do |request, response|  body = request.bodyโ€‹  if body["email"].nil? || body["password"].nil?    return response.json({ error: "Email and password are required" }, 400)  endโ€‹  db = Tina4.databaseโ€‹  # Find the user  user = db.fetch_one(    "SELECT id, name, email, password_hash FROM users WHERE email = ?",    [body["email"]]  )โ€‹  if user.nil?    return response.json({ error: "Invalid email or password" }, 401)  endโ€‹  # Check the password  unless Tina4::Auth.check_password(body["password"], user["password_hash"])    return response.json({ error: "Invalid email or password" }, 401)  endโ€‹  # Generate a token  token = Tina4::Auth.get_token({    user_id: user["id"],    email: user["email"],    name: user["name"]  })โ€‹  response.json({    message: "Login successful",    token: token,    user: {      id: user["id"],      name: user["name"],      email: user["email"]    }  })end

Notice the @noauth comment. The login endpoint must be public -- you cannot require a token to get a token.

bash
curl -X POST http://localhost:7147/api/login \  -H "Content-Type: application/json" \  -d '{"email": "alice@example.com", "password": "securePass123"}'
json
{  "message": "Login successful",  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",  "user": {    "id": 1,    "name": "Alice",    "email": "alice@example.com"  }}

The client stores this token (in localStorage, a cookie, or memory) and sends it with subsequent requests.


5. Using Tokens in Requests#

The client sends the token in the Authorization header with the Bearer prefix:

bash
curl http://localhost:7147/api/profile \  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

6. Protecting Routes#

Auth Middleware#

Create a reusable auth middleware:

ruby
def auth_middleware(request, response, next_handler)  auth_header = request.headers["Authorization"] || ""โ€‹  if auth_header.empty? || !auth_header.start_with?("Bearer ")    return response.json({ error: "Authorization header required" }, 401)  endโ€‹  token = auth_header.sub("Bearer ", "")โ€‹  payload = Tina4::Auth.valid_token(token)  if payload.nil?    return response.json({ error: "Invalid or expired token" }, 401)  endโ€‹  request.user = payload  # Attach user data to the requestโ€‹  next_handler.call(request, response)end

Applying Middleware to Routes#

ruby
Tina4::Router.get("/api/profile", middleware: "auth_middleware") do |request, response|  response.json({    user_id: request.user["user_id"],    email: request.user["email"],    name: request.user["name"]  })end
bash
# Without token -- 401curl http://localhost:7147/api/profile
json
{"error":"Authorization header required"}
bash
# With valid token -- 200curl http://localhost:7147/api/profile \  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
json
{  "user_id": 1,  "email": "alice@example.com",  "name": "Alice"}

Applying Middleware to a Group#

ruby
Tina4::Router.group("/api/admin", middleware: "auth_middleware") doโ€‹  Tina4::Router.get("/stats") do |request, response|    response.json({ active_users: 42 })  endโ€‹  Tina4::Router.get("/logs") do |request, response|    response.json({ logs: [] })  endโ€‹end

Every route in the group requires a valid token.


7. @noauth and @secured Decorators#

As introduced in Chapter 2, these decorators control authentication at the route level.

@noauth -- Skip Authentication#

Use @noauth for public endpoints that should bypass any global or group-level auth:

ruby
# @noauthTina4::Router.get("/api/public/health") do |request, response|  response.json({ status: "ok" })endโ€‹# @noauthTina4::Router.post("/api/login") do |request, response|  # Login logicendโ€‹# @noauthTina4::Router.post("/api/register") do |request, response|  # Registration logicend

@secured -- Require Authentication for GET Routes#

By default, POST, PUT, PATCH, and DELETE routes are considered secured. GET routes are public. Use @secured to explicitly protect a GET route:

ruby
# @securedTina4::Router.get("/api/me") do |request, response|  # This GET route requires authentication  response.json(request.user)end

Role-Based Authorization#

Combine auth middleware with role checks:

ruby
def require_role(role)  lambda do |request, response, next_handler|    # First check authentication    auth_header = request.headers["Authorization"] || ""    if auth_header.empty? || !auth_header.start_with?("Bearer ")      return response.json({ error: "Authorization required" }, 401)    endโ€‹    token = auth_header.sub("Bearer ", "")    payload = Tina4::Auth.valid_token(token)    if payload.nil?      return response.json({ error: "Invalid or expired token" }, 401)    endโ€‹    # Check role    if (payload["role"] || "") != role      return response.json({ error: "Forbidden. Required role: #{role}" }, 403)    endโ€‹    request.user = payload    next_handler.call(request, response)  endend

8. CSRF Protection#

For traditional form-based applications (not SPAs), Tina4 provides CSRF protection with form tokens.

Generating a Token#

In your template, include the CSRF token in every form:

html
<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 field:

html
<input type="hidden" name="_token" value="abc123randomtoken456">

Validating the Token#

In your route handler, check the token:

ruby
Tina4::Router.post("/profile/update") do |request, response|  # Validate CSRF token (form_token() renders a signed JWT, validated like any token)  unless Tina4::Auth.valid_token(request.body["_token"] || "")    return response.json({ error: "Invalid form token. Please refresh and try again." }, 403)  endโ€‹  # Process the form...  response.redirect("/profile")end

9. Sessions#

Tina4 supports server-side sessions for storing per-user state between requests. Sessions work alongside JWT tokens -- use JWTs for API authentication and sessions for stateful web pages.

Using Sessions#

Access session data via request.session:

ruby
Tina4::Router.post("/login-form") do |request, response|  # After validating credentials...  request.session["user_id"] = 42  request.session["user_name"] = "Alice"  request.session["logged_in"] = trueโ€‹  response.redirect("/dashboard")endโ€‹Tina4::Router.get("/dashboard") do |request, response|  if request.session["logged_in"].nil?    return response.redirect("/login")  endโ€‹  response.render("dashboard.html", {    user_name: request.session["user_name"]  })endโ€‹Tina4::Router.post("/logout") do |request, response|  # Clear all session data  request.session.clearโ€‹  response.redirect("/login")end

10. Exercise: Build Login, Register, and Profile#

Build a complete authentication system with registration, login, profile viewing, and password changing.

Requirements#

  1. Create a users table migration with: id, name, email (unique), password_hash, role (default "user"), created_at
  2. Build these endpoints:
MethodPathAuthDescription
POST/api/register@noauthCreate an account. Validate name, email, password (min 8 chars).
POST/api/login@noauthLogin. Return JWT token.
GET/api/profilesecuredGet current user's profile from token.
PUT/api/profilesecuredUpdate name and email.
PUT/api/profile/passwordsecuredChange password. Require current password.
  1. Create auth middleware that extracts the user from the JWT and attaches it to request.user.

Test with:#

bash
# Registercurl -X POST http://localhost:7147/api/register \  -H "Content-Type: application/json" \  -d '{"name": "Alice", "email": "alice@example.com", "password": "securePass123"}'โ€‹# Logincurl -X POST http://localhost:7147/api/login \  -H "Content-Type: application/json" \  -d '{"email": "alice@example.com", "password": "securePass123"}'โ€‹# Save the token from login response, then:โ€‹# Get profilecurl http://localhost:7147/api/profile \  -H "Authorization: Bearer YOUR_TOKEN_HERE"โ€‹# Update profilecurl -X PUT http://localhost:7147/api/profile \  -H "Authorization: Bearer YOUR_TOKEN_HERE" \  -H "Content-Type: application/json" \  -d '{"name": "Alice Smith"}'โ€‹# Change passwordcurl -X PUT http://localhost:7147/api/profile/password \  -H "Authorization: Bearer YOUR_TOKEN_HERE" \  -H "Content-Type: application/json" \  -d '{"current_password": "securePass123", "new_password": "evenMoreSecure456"}'โ€‹# Try with no token (should fail)curl http://localhost:7147/api/profile

11. Solution#

Migration#

Create migrations/20260322160000_create_auth_users_table.sql:

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;

Middleware#

Create src/routes/middleware.rb:

ruby
def auth_middleware(request, response, next_handler)  auth_header = request.headers["Authorization"] || ""โ€‹  if auth_header.empty? || !auth_header.start_with?("Bearer ")    return response.json({ error: "Authorization required. Send: Authorization: Bearer <token>" }, 401)  endโ€‹  token = auth_header.sub("Bearer ", "")โ€‹  payload = Tina4::Auth.valid_token(token)  if payload.nil?    return response.json({ error: "Invalid or expired token. Please login again." }, 401)  endโ€‹  request.user = payloadโ€‹  next_handler.call(request, response)end

Routes#

Create src/routes/auth.rb:

ruby
# @noauthTina4::Router.post("/api/register") do |request, response|  body = request.bodyโ€‹  errors = []  errors << "Name is required" if body["name"].nil? || body["name"].empty?  errors << "Email is required" if body["email"].nil? || body["email"].empty?  if body["password"].nil? || body["password"].empty?    errors << "Password is required"  elsif body["password"].length < 8    errors << "Password must be at least 8 characters"  endโ€‹  unless errors.empty?    return response.json({ errors: errors }, 400)  endโ€‹  db = Tina4.databaseโ€‹  existing = db.fetch_one("SELECT id FROM users WHERE email = ?", [body["email"]])  unless existing.nil?    return response.json({ error: "Email already registered" }, 409)  endโ€‹  hash = Tina4::Auth.hash_password(body["password"])โ€‹  db.execute(    "INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?)",    [body["name"], body["email"], hash]  )โ€‹  user = db.fetch_one("SELECT id, name, email, role, created_at FROM users WHERE id = last_insert_rowid()")โ€‹  response.json({ message: "Registration successful", user: user }, 201)endโ€‹# @noauthTina4::Router.post("/api/login") do |request, response|  body = request.bodyโ€‹  if body["email"].nil? || body["password"].nil?    return response.json({ error: "Email and password are required" }, 400)  endโ€‹  db = Tina4.databaseโ€‹  user = db.fetch_one(    "SELECT id, name, email, password_hash, role FROM users WHERE email = ?",    [body["email"]]  )โ€‹  if user.nil? || !Tina4::Auth.check_password(body["password"], user["password_hash"])    return response.json({ error: "Invalid email or password" }, 401)  endโ€‹  token = Tina4::Auth.get_token({    user_id: user["id"],    email: user["email"],    name: user["name"],    role: user["role"]  })โ€‹  response.json({    message: "Login successful",    token: token,    user: { id: user["id"], name: user["name"], email: user["email"], role: user["role"] }  })endโ€‹# Get current user profileTina4::Router.get("/api/profile", middleware: "auth_middleware") do |request, response|  db = Tina4.databaseโ€‹  user = db.fetch_one(    "SELECT id, name, email, role, created_at FROM users WHERE id = ?",    [request.user["user_id"]]  )โ€‹  if user.nil?    return response.json({ error: "User not found" }, 404)  endโ€‹  response.json(user)endโ€‹# Update profileTina4::Router.put("/api/profile", middleware: "auth_middleware") do |request, response|  db = Tina4.database  body = request.body  user_id = request.user["user_id"]โ€‹  if body["email"]    existing = db.fetch_one(      "SELECT id FROM users WHERE email = ? AND id != ?",      [body["email"], user_id]    )    unless existing.nil?      return response.json({ error: "Email already in use by another account" }, 409)    end  endโ€‹  current = db.fetch_one("SELECT * FROM users WHERE id = ?", [user_id])โ€‹  db.execute(    "UPDATE users SET name = ?, email = ? WHERE id = ?",    [body["name"] || current["name"], body["email"] || current["email"], user_id]  )โ€‹  updated = db.fetch_one(    "SELECT id, name, email, role, created_at FROM users WHERE id = ?",    [user_id]  )โ€‹  response.json({ message: "Profile updated", user: updated })endโ€‹# Change passwordTina4::Router.put("/api/profile/password", middleware: "auth_middleware") do |request, response|  db = Tina4.database  body = request.body  user_id = request.user["user_id"]โ€‹  if body["current_password"].nil? || body["new_password"].nil?    return response.json({ error: "Current password and new password are required" }, 400)  endโ€‹  if body["new_password"].length < 8    return response.json({ error: "New password must be at least 8 characters" }, 400)  endโ€‹  user = db.fetch_one("SELECT password_hash FROM users WHERE id = ?", [user_id])โ€‹  unless Tina4::Auth.check_password(body["current_password"], user["password_hash"])    return response.json({ error: "Current password is incorrect" }, 401)  endโ€‹  new_hash = Tina4::Auth.hash_password(body["new_password"])โ€‹  db.execute(    "UPDATE users SET password_hash = ? WHERE id = ?",    [new_hash, user_id]  )โ€‹  response.json({ message: "Password changed successfully" })end

Expected output for register:

json
{  "message": "Registration successful",  "user": {    "id": 1,    "name": "Alice",    "email": "alice@example.com",    "role": "user",    "created_at": "2026-03-22 16:00:00"  }}

(Status: 201 Created)

Expected output for profile without token:

json
{"error":"Authorization required. Send: Authorization: Bearer <token>"}

(Status: 401 Unauthorized)

Expected output for successful password change:

json
{"message":"Password changed successfully"}

12. Gotchas#

1. Token Expiry Confusion#

Problem: Tokens that worked yesterday now return 401.

Cause: The default token lifetime is 1 hour (3600 seconds). After that, the token is invalid even if the signature is correct.

Fix: Issue a new token at login. If your application needs long-lived sessions, use refresh tokens: a short-lived access token (15 minutes) paired with a long-lived refresh token (7 days) that can be used to get a new access token without re-entering credentials.

2. Secret Key Management#

Problem: Tokens generated on one server are invalid on another, or tokens stop working after a deployment.

Cause: Each server generated its own RSA keys in .keys/. Or the key files were deleted/regenerated during deployment.

Fix: Set TINA4_SECRET in .env explicitly and use the same value across all servers. Store it in your deployment secrets manager (not in version control). If the key changes, all existing tokens become invalid and users must log in again.

3. CORS with Authentication#

Problem: Frontend requests with the Authorization header fail with a CORS error, even though CORS_ORIGINS=* is set.

Cause: When the browser sends an Authorization header, it first sends a preflight OPTIONS request. The server must respond to the OPTIONS request with the correct CORS headers, including Access-Control-Allow-Headers: Authorization.

Fix: Tina4 handles preflight requests automatically. Make sure CORS_ORIGINS is set correctly. If it is still failing, check that you are not overriding CORS headers in middleware.

4. Storing Tokens in localStorage#

Problem: Your token is stolen via an XSS attack because it was stored in localStorage.

Cause: Any JavaScript on the page can read localStorage, including injected scripts from an XSS vulnerability.

Fix: Store tokens in httpOnly cookies when possible -- they cannot be accessed by JavaScript. For SPAs that must use localStorage, implement strict Content Security Policy headers and sanitize all user input.

5. Forgetting @noauth on Login#

Problem: Your login endpoint returns 401 -- you cannot log in because the endpoint requires authentication.

Cause: If you have global auth middleware, the login endpoint needs the @noauth annotation to bypass it.

Fix: Add # @noauth before your login and register routes. Without it, users cannot authenticate because authentication is required to authenticate -- a catch-22.

6. Password Hash Column Too Short#

Problem: Registration fails with a database error about the password hash being too long.

Cause: BCrypt/PBKDF2 hashes can be long. If your password_hash column is defined as VARCHAR(50), it gets truncated.

Fix: Use TEXT for the password hash column, or at minimum VARCHAR(255). Never constrain the hash length.

7. Token in URL Query Parameters#

Problem: Tokens in URLs like /api/profile?token=eyJ... leak through browser history, server logs, and the Referer header.

Cause: Query parameters are visible in many places where headers are not.

Fix: Always send tokens in the Authorization header, never in the URL. The only exception is WebSocket connections, where the initial HTTP upgrade request cannot carry custom headers -- use a short-lived token for that case.