---
title: Auth
description: Session-based authentication, the prebuilt login form, and the User plugin pattern from production.
---

Nibiru ships a session-based authentication core (`Nibiru\Auth`) and a `users` module that gives you a working login form, an authorisation check, and the database schema in three commands.

## The Auth core

`Auth::auth($login, $password)` is the lowest-level call. It:

1. Looks up the user by `user_login`.
2. Decrypts the stored password using the salt from `[SECURITY] password_hash`.
3. On match, replaces `$_SESSION` with `['auth' => ['session_id' => …, 'user_id' => …, 'login' => …]]`.

```php
$auth = new \Nibiru\Auth();
if ($auth->auth($_POST['login'], $_POST['password'])) {
    View::forwardTo('/dashboard');
} else {
    View::assign(['error' => 'Invalid credentials.']);
}
```

:::caution
The default schema stores passwords with **AES_DECRYPT** keyed by the INI salt — reversible. Adequate for prototyping, **not** for public production. Replace it with `password_hash()` / `password_verify()` for real apps. See *Hardening*, below.
:::

## The Users module

Generate it once with the CLI:

```bash
./nibiru -m users
```

Then run the matching migrations:

```bash
./nibiru -mi local
```

You now have:

- `application/module/users/` — the module folder.
- `users` table — created by `005-user.sql`.
- `User` plugin — `Nibiru\Module\Users\Plugin\User` with `isAuthorized()`, `loginForm()`, `currentUser()`.

## A complete login flow

`application/controller/loginController.php`:

```php
<?php
namespace Nibiru;
use Nibiru\Adapter\Controller;
use Nibiru\Module\Users\Plugin\User;

class loginController extends Controller
{
    private User $user;

    public function __construct() {
        parent::__construct();
        $this->user = new User();
    }

    public function pageAction() {
        if ($this->user->isAuthorized()) {
            View::forwardTo('/');
            return;
        }
        View::assign([
            'title'     => 'Sign in',
            'loginForm' => $this->user->loginForm(),
        ]);
    }

    public function submitAction() {
        if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
        $auth = new Auth();
        if ($auth->auth($_POST['login'] ?? '', $_POST['password'] ?? '')) {
            View::forwardTo('/');
        } else {
            View::assign(['error' => 'Invalid login.']);
        }
    }

    public function logoutAction() {
        unset($_SESSION['auth']);
        session_regenerate_id(true);
        View::forwardTo('/login');
    }

    public function navigationAction() {
        JsonNavigation::getInstance()->loadJsonNavigationArray();
    }
}
```

`application/view/templates/login.tpl`:

```smarty
{include 'shared/header.tpl'}
<body>
{include file="navigation.tpl"}
<main class="container">
  <h1>{$title}</h1>
  {if $error}<div class="alert alert-danger">{$error}</div>{/if}
  {$loginForm nofilter}
</main>
{include 'shared/footer.tpl'}
</body>
```

`Form::action="/login/submit"` is set inside `loginForm()` so the form posts to the right action.

## Guarding controllers

The simple check:

```php
public function pageAction() {
    if (!$this->user->isAuthorized()) {
        View::forwardTo('/login');
        return;
    }
    // ...
}
```

For role-aware checks, the showcase apps use the `Acl` plugin from the same module:

```php
use Nibiru\Module\Users\Plugin\Acl;

if (!Acl::can('edit', 'documents')) {
    View::forwardTo('/forbidden');
    return;
}
```

Tables `acl`, `user_to_acl`, `acl-data` (migrations 001, 008, 011) form the role/permission base.

## Hardening

For production, replace `Auth::auth()` with a hardened version:

```php
namespace Nibiru;
use Nibiru\Pdo;

class HardenedAuth
{
    public function auth(string $login, string $password): bool {
        $row = Pdo::fetchRow(
            'SELECT user_id, user_pass FROM "user" WHERE user_login = :l AND user_account_active = 1',
            [':l' => $login]
        );
        if (!$row || !password_verify($password, $row['user_pass'])) {
            return false;
        }
        if (password_needs_rehash($row['user_pass'], PASSWORD_ARGON2ID)) {
            Pdo::update('user', [
                'user_pass' => password_hash($password, PASSWORD_ARGON2ID),
            ], ['user_id' => $row['user_id']]);
        }
        session_regenerate_id(true);
        $_SESSION['auth'] = [
            'session_id' => session_id(),
            'user_id'    => $row['user_id'],
            'login'      => $login,
        ];
        return true;
    }
}
```

Migrate existing rows on first login with `password_needs_rehash`.

## CSRF

Nibiru doesn't generate CSRF tokens for you. Add them yourself:

```php
public function pageAction() {
    if (!isset($_SESSION['csrf'])) {
        $_SESSION['csrf'] = bin2hex(random_bytes(16));
    }
    View::assign(['csrf' => $_SESSION['csrf']]);
}

public function submitAction() {
    if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
        http_response_code(419);
        return;
    }
    // ...handle submission...
}
```

Embed `<input type="hidden" name="csrf" value="{$csrf}">` in your form.

## QR-code login (TPMS pattern)

Production apps include a QR-code-based magic-link login that issues short-lived tokens. The flow:

1. User scans QR → URL is `/login/token/<one-time-token>`.
2. `tokenAction` validates and creates a session.

The framework already depends on `bacon/bacon-qr-code` and `picqer/php-barcode-generator` via Composer, so you can render QR codes inline:

```php
$writer = new \BaconQrCode\Writer(new \BaconQrCode\Renderer\ImageRenderer(
    new \BaconQrCode\Renderer\RendererStyle\RendererStyle(220),
    new \BaconQrCode\Renderer\Image\SvgImageBackEnd()
));
$svg = $writer->writeString('https://app.example.com/login/token/' . $token);
View::assign(['qr' => $svg]);
```

## Common pitfalls

- **`$_SESSION` is replaced, not merged** by the default `Auth::auth()`. Anything you stored before login is lost. Save and restore explicitly if needed.
- **No rate limiting**. Add Fail2Ban or a middleware-style observer.
- **Session cookies need flags.** Set `session.cookie_secure = 1`, `session.cookie_httponly = 1`, and `session.cookie_samesite = "Lax"` in php.ini for production.
