trail / auth
Users, passwords, passkeys, + backup codes for trail
Requires
- php: >=8.4
- ext-openssl: *
- ext-sodium: *
- trail/db: ^0.1
- trail/framework: ^0.1
- trail/mail: ^0.1
- trail/sessions: ^0.1
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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
loginis 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'),
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
trustEmailis 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 instorage/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,
passwordSignupoff with no oidc setup means registrations are closedpasswordsoff means you'll need oidc, passkey, or a backup code to get in- existing password accounts cant signin once
passwordsare 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.proxiesto 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-storeso freeze and CDNs never keep them - oidc uses pkce
not done yet
- authenticator app 2fa (might skip)