Authentication & Security

Craft contains modular authentication and authorization components mapped to clean, static facade accessors. It also ships optional security utilities — a Post-Quantum Cryptography (PQC) token-signing helper and a CAPTCHA integration — that you wire in explicitly where needed. Session cookies do not use PQC: they are signed with HMAC-SHA256 (see Sessions and CSRF below).


Authentication (Auth Facade)

Use the Auth facade to authenticate user credentials and query the current session state:

from craft.facades import Auth

# Attempt authentication using login attributes
if Auth.attempt({"email": "[email protected]", "password": "securepassword"}):
    # Retrieve current authenticated User model instance
    user = Auth.user()
    print(f"Authenticated as {user.name}")

# Check if a user session is active
if Auth.check():
    # Logout current user session
    Auth.logout()

The user model

A model used as the authentication subject mixes in AuthenticatableMixin, which hashes the password attribute on the way to the database. The mixin comes before Model:

from craft.auth.models import AuthenticatableMixin
from craft.orm.model import Model


class User(AuthenticatableMixin, Model):
    __table__ = "users"
    fillable = ["name", "email", "password"]
    hidden = ["password", "remember_token"]

class User(Model, AuthenticatableMixin) raises TypeError when the class is created. In that order Model.force_create() and Model.save() win the method resolution order, the hashing hook never runs and the column keeps the plaintext - while a login still succeeds. SoftDeletes and TenantScoped carry the same base-order guard.


User Roles & Types

Craft supports three user roles/types out-of-the-box:

  1. User (user): A standard user account.
  2. Admin (admin): A system administrator account with full administrative credentials.
  3. Tenant (tenant): A tenant account representing an isolated workspace/SaaS client.

Checking the User Type

The users table carries a type column (user, admin, tenant) and an is_admin flag. Read them like any other attribute:

user = Auth.user()

if user.get_attribute("is_admin"):
    # Perform administrator actions
    pass

if user.get_attribute("type") == "tenant":
    # Perform tenant actions
    pass

For anything richer than a column check, define a gate (below) and ask the gate instead.


Authorization & Gates (Gate Facade)

Authorization checks allow or deny requests based on user permissions.

Defining Gates

Gates are closure callbacks mapped to action permissions (typically declared inside AppServiceProvider or a dedicated provider):

from craft.facades import Auth, Gate

# Define a gate rule
Gate.define("publish-posts", lambda user: user.get_attribute("is_admin"))

# Check the gate permissions in a controller — the user is always passed explicitly
if not Gate.allows("publish-posts", Auth.user()):
    return self.json({"message": "Unauthorized"}, status=403)

Gate.allows(ability, user, *args) requires the user as the second argument; Gate.denies(...) is its negation, and Gate.authorize(...) raises an AuthorizationException (403) instead of returning False.

Gate.allows checks, in order: (1) a registered ability closure, (2) a policy on the target model, (3) the RBAC permission system — user.has_permission(ability), if the user carries that method — before denying by default. That third tier means a permission slug like "manage-users" works as a Gate ability without anyone having to register a closure for it. See Authorization (RBAC) for roles, permissions, and the role:/permission: route middleware built on top of it.


Authorization Policies

Policies organize authorization checks around specific models.

Example Policy

class PostPolicy:
    def update(self, user, post) -> bool:
        # User can update a post only if they own it
        return user.id == post.user_id

    def delete(self, user, post) -> bool:
        return user.get_attribute("is_admin") or user.id == post.user_id

Registering & Checking Policies

from app.Models.Post import Post

# Register Policy mapping (usually inside AuthServiceProvider)
Gate.policy(Post, PostPolicy)

# Check policy permissions in a controller action
post = Post.find("post-uuid")
if not Gate.check("update", user, post):
    return self.json({"message": "Forbidden"}, status=403)

Sessions and CSRF

Sessions are signed with HMAC-SHA256 using APP_KEY. A cookie that was tampered with, or signed with a different key, is rejected rather than trusted.

Signed is not encrypted. With SESSION_DRIVER=cookie the payload is base64-encoded JSON in the cookie — the client cannot change it, but can read it. Do not put secrets in the session under that driver. Use SESSION_DRIVER=file, which keeps the payload on the server and puts only a signed id in the cookie.

CSRF is verified on POST/PUT/PATCH/DELETE, via the _token field in the request body or the X-CSRF-TOKEN header. A token in the query string is ignored — it could be planted by a crafted cross-site link. Routes matching api/* are exempt. A mismatch returns 419 with code CSRF_TOKEN_MISMATCH and message_key security.csrf.token_mismatch; an Origin or Referer naming another site returns 403 CSRF_ORIGIN_REJECTED (security.csrf.origin_rejected). Both keys ship in en, pt-BR and es.

redirect.back(request) follows the Referer only when it is a relative path or a URL on the request's own host or on the APP_URL host (the public address behind a reverse proxy); anything else - another site, //host, javascript: - goes to the fallback instead, so a form is never an open redirect.

<form method="POST" action="/posts">
    @csrf
    <input name="title">
</form>

Post-Quantum Cryptography (PQC)

Craft ships a hash-based one-time signature utility (WOTS, a Lamport-style scheme over SHA-256) for signing tokens where post-quantum resistance matters. Its security rests only on the preimage resistance of SHA-256, which holds against quantum adversaries.

from craft.facades import PQC
from craft.security.pqc import WOTS

seed = b"..."  # 32 random bytes — one seed must sign only ONE message
public_key = WOTS(seed).get_public_key()

token = PQC.sign_token(payload, secret_key, seed)
PQC.verify_token(token, secret_key, public_key)

How it works:

It is a standalone utility — session cookies use HMAC-SHA256, not WOTS. Wire PQC in explicitly where you need it.


Captcha Integration

A short alphanumeric challenge held in the session. Both calls take the request — the code lives in the session, not in a token you pass around.

from craft.facades import Captcha
from craft.http.response import Response

# In the GET handler: generate and render the challenge
code = Captcha.generate(request)
html = Captcha.get_obfuscated_html(code)

# In the POST handler: validate what the user typed
if not Captcha.validate(request, request.input("captcha")):
    return Response("Invalid Captcha Challenge", status=400)

The stored code is cleared on every validation attempt, whether or not it succeeded — a captcha is single-use, otherwise one challenge can be brute-forced.


Web Application Firewall (WAF) & Intrusion Detection (IDS)

Craft includes an integrated WAF and threat intelligence subsystem with IP reputation scoring, payload inspection, and automatic blacklisting.

Protecting Routes via Middleware

Attach the firewall middleware alias to any route or group:

Route.post("/api/v1/orders", [OrderController, "store"]) \
    .middleware("firewall", "api")

Threat Signatures Detected in Real Time

The firewall inspects URIs, query strings, and payloads against regex threat patterns:

Managing Firewall Rules Programmatically

Use the Firewall facade to manage reputation and rules:

from craft.facades import Firewall

# Add trusted IP to whitelist (bypasses rate limits and inspection)
Firewall.whitelist_ip("192.168.1.50")

# Add malicious IP to blacklist (immediately returns 403 Forbidden)
Firewall.blacklist_ip("203.0.113.88", reason="Automated scanner detected")

# Check reputation score (threshold: 100 points = auto-blacklisted)
score = Firewall.get_reputation_score("203.0.113.88")

Honeypot & Brute-Force Protection

Craft provides active deception defense and database-backed login cooldowns.

Pre-Registered Attacker Honeypot

Commonly abused administrative usernames (admin, root, administrator, postgres, superuser, operator, master, system, etc.) are pre-configured as honeypot traps:

  1. Any login attempt targeting these usernames is blocked immediately.
  2. Never exposes real user models or query errors.
  3. Records a HONEYPOT security event in auth_audit_logs and security_events.
  4. Automatically assigns a 30-minute cooldown to the attacking IP address.

Brute-Force Cooldown (30 Minutes)

On normal user accounts, 5 consecutive failed login attempts activate a 30-minute cooldown stored in PostgreSQL (auth_cooldowns). Subsequent attempts during cooldown are refused before credentials are checked. A successful login clears the failed attempt counter.

Using Auth with Audit Tracking

Pass the client IP and User-Agent to Auth.attempt() to enable automatic auditing and cooldown enforcement:

if Auth.attempt(
    {"email": email, "password": password},
    ip=request.ip(),
    user_agent=request.headers.get("user-agent"),
):
    return redirect(route="dashboard")

Hash-Chained Sign-In Audit Log

Every row written to auth_audit_logs is linked to the row before it. The row stores the previous row's digest in previous_hash and its own in chain_hash: a SHA-256 over the previous hash, IP address, username, user agent, result, reason and creation time, joined with a NUL byte. The first row points at 64 zeros. Editing, reordering or removing a sign-in record changes a digest the next row no longer matches, so recomputing the chain from the first row proves the log was not altered.

The columns come from the forward-only migration 2026_10_07_000006_add_hash_chain_to_auth_audit_logs.py; run python dev.py migrate on an existing database. Until it has run, audit rows are still written, unchained, and the chain starts with the first row written afterwards. The chain assumes one writer at a time: two sign-in attempts recorded at the same instant can read the same previous_hash, which a recomputation reports as a fork.


API Token Hashing & Database SSL

Hashed API Tokens

API tokens provided in Authorization: Bearer <token> are verified against SHA-256 hashes (api_token_hash or api_token column) with automatic backward compatibility. Raw tokens are never required to reside in plaintext in the database.

PostgreSQL SSL Mode

Configure SSL enforcement in config/database.py or .env via DB_SSLMODE:

# config/database.py
"pgsql": {
    "driver": "postgresql",
    "host": env("DB_HOST", "127.0.0.1"),
    "port": env("DB_PORT", 5432),
    "database": env("DB_DATABASE", "craft_db"),
    "username": env("DB_USERNAME", "craft"),
    "password": env("DB_PASSWORD", ""),
    "sslmode": env("DB_SSLMODE", "prefer"),  # 'require', 'verify-full', 'prefer'
}