Search by

trail / auth

dodgycoffee

Users, passwords, passkeys, + backup codes for trail

Package info

source.tube/brindly/trail-auth

Issues

Type:trail-addon

pkg:composer/trail/auth

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

dev-main / 0.1.x-dev 2026-10-01 04:56 UTC

This package is auto-updated.

Last update: 2026-10-01 04:57:02 UTC


README

A trail framework addon for handling users + auth

install

composer require trail/auth
trail/trail db::migrate

Pulls in trail/db, trail/sessions, and trail/mail. PHP needs argon2 support (most builds have it), openssl, and sodium

Make sure you set the site url cause mail links and passkeys both need it

// app.php
'url' => 'https://example.com',

whats included

Pages under /auth: signup, signin, signout, forgot, reset, verify, recover (backup codes), confirm, password, and account (email, passkeys, backup codes, and devices).

All of them are named routes (auth.signin, auth.account, ...), so Route::url('auth.signin') works and the prefix can change

protecting pages

use Trail\Auth\Gates\RequireUser;
use Trail\Auth\Gates\RequireFresh;

Route::get('/dashboard', Dashboard::class)->gate(RequireUser::class);

// signed in within last 15 minutes
Route::post('/delete-everything', Nuke::class)->gate(RequireUser::class, RequireFresh::class);

Whos asking comes from Visitor. Like session, ask for it in handle()

use Trail\Auth\Users\Visitor;

public function handle(Visitor $visitor): Response
{
    if ($visitor->isGuest()) { ... }

    $visitor->user->id;
    $visitor->user->username;
    $visitor->user->email;     // verified only
}

usernames and emails

'auth' => [
    'login' => ['username', 'email'],   // either or both
],
  • whatever's in login is required on sign up. Email is optional otherwise
  • usernames can't have @ so the login knows which is which automatically
  • usernames are lowercase a-z 0-9 . _ -, 3 to 32 long
  • emails are always verified before they're added. Until the link is clicked it's just pending
  • email only sites sign people in after they verify. Username sites sign them in right away

Skip the verify mail if you don't need it

'autoVerify' => static fn (string $email): bool => str_ends_with($email, '@club.test'),

mail

Verify and reset links go out throug trail/mail. Set app.mail.dsn and app.mail.from (see mail readme). Without a dsn dev mode logs mail, and prod logs a warning

passwords

argon2id rehashed on sign in whenever settings change. Each hash already has its own random salt

A pepper is a per site secret on top of that. Lives in .env only

'pepper' => App::env('AUTH_PEPPER'),

Rotate by moving the old one over. People get rehashed to new one as they sign in

'pepper' => App::env('AUTH_PEPPER'),
'oldPeppers' => [App::env('AUTH_PEPPER_OLD')],

Lose the pepper and every password is dies.

Backup codes use the pepper too. old peppers keep old codes working

passkeys

Added from account page. Soon as someone adds one the account is set to passkey only. Meaning the password is deleted and they get 10 backup codes instead

  • lost the passkey? you use a backup code (burn on use), which tuirns off passkeys, logs you in, and has you set a new password
  • passkey users can switch back to a password on purpose from account page
  • passkey users cant use email resets. Backup codes only
  • last working passkey cant be removed (unless an oidc is connected)
  • more than one passkey is fine

By default the rp id is the host in app.url. Change that for subdomains

'passkeys' => [
    'name' => 'A Site',
    'rpId' => 'example.com',
    'origins' => ['https://example.com', 'https://www.example.com'],
],

// or turn them off
'passkeys' => false,

OIDC sign in

You can also use an OIDC (PocketID, Authentik, Authelia, Keycloak, etc) to sign in/register, etc. Create a public client on it (it uses PKCE) with this callback

https://example.com/auth/oidc/{key}/callback
'providers' => [
    'pocketid' => [
        'name' => 'Pocket ID',                                // button text
        'issuer' => App::env('OIDC_POCKETID_ISSUER'),         // https://id.example.com
        'clientId' => App::env('OIDC_POCKETID_CLIENT_ID'),
        'icon' => '/images/pocketid.svg',                     // optional

        // regiser with OIDC
        'signup' => true,

        // link to account with matching email
        'trustEmail' => false,
    ],
],

Here's a few notes of what happens with what and when

  • connecting an OIDC replaces the password, making it the only login option (like passkeys)
  • passkeys + OIDC can work together/at the same time
  • switchingback to password turns passkeys off + disconnects oidc
  • you cant remove your last login method
  • email resets dont happen for accounts without passwords

Other bits

  • sign in, sign up, and confirm pages get a new button per oidc
  • account page can disconnect oidc
  • logins are matched to the oidc's user id
  • matching email only links when trustEmail is on and the oidc says it's verified
  • new accounts take the oidc's username if it's free / valid, and the email if verified
  • needs allow_url_fopen. Discovery + keys cache in storage/cache/auth

Or you can just ignore passwords altogether like an adult:

// oidc signup only if false
'passwordSignup' => false,

// oidc + passkeys signin only
'passwords' => false,
  • passwordSignup off with no oidc setup means registrations are closed
  • passwords off means you'll need oidc, passkey, or a backup code to get in
  • existing password accounts cant signin once passwords are off. connect oidc first

all the config stuff

'auth' => [

    // default routes. false + define your own using same names
    'routes' => true,
    'prefix' => '/auth',

    // landing after signing in/out
    'home' => '/',

    'login' => ['username', 'email'],
    'usernames' => '/^[a-z0-9_.-]{3,32}$/D',
    'reserved' => ['admin', 'root'],
    'passwordLength' => 10,

    'pepper' => App::env('AUTH_PEPPER'),
    'oldPeppers' => [],

    // password_hash() options
    'hashOptions' => [],

    // seconds a sign in counts as fresh
    'fresh' => 900,

    'throttle' => [
        'window' => 900,
        'account' => 10,
        'ip' => 50,
        'signups' => 10,
    ],

    'autoVerify' => null,

    // extra sign up fields here
    'onSignUp' => static function (User $user, Request $request): void { ... },

    'passkeys' => [],

    // oidc
    'providers' => [],
    'passwordSignup' => true,
    'passwords' => true,
],

templates

Templates live in auth/. Make a new file with the same name in your local templates folder and yours wins

<?php $this->layout('_layouts/_base', ['title' => $title]) ?>
<?= $body ?>

Passkey buttons are data- attributes and a tiny script at /auth/passkey.js.

cli

trail/trail auth::add --username=alex --email=alex@example.com   # prints a set password link
trail/trail auth::reset alex                                     # reset link
trail/trail auth::prune                                          # dead links, old attempts, expired sessions

security bits

  • sign in swaps the session token. sign out destroys it
  • there is a throttle for attempts. ipv6 uses /64
  • if using a load balancer or cdn, set app.proxies to prevent shared throttle
  • verify mails are capped at 3 per user per window
  • reset and verify links need to actually be clicked to work
  • password changes, resets, recoveries, and switching to passkeys sign out every other device
  • auth pages send Cache-Control: no-store so freeze and CDNs never keep them
  • oidc uses pkce

not done yet

  • authenticator app 2fa (might skip)