enlivenapp / flight-shield
Authentication and authorization for FlightPHP, ported from CodeIgniter Shield
Package info
github.com/enlivenapp/FlightPHP-Shield
Type:flightphp-plugin
pkg:composer/enlivenapp/flight-shield
Requires
- php: >=8.1
- enlivenapp/flight-csrf: ^0.2
- enlivenapp/flight-school: ^0.3
- enlivenapp/flight-settings: ^0.2
- flightphp/core: ^3.0
Requires (Dev)
- phpunit/phpunit: ^11.0
Suggests
- firebase/php-jwt: Required for JWT authentication (^6.0)
README
Flight Shield
I noticed folks downloading some of these packages. I'm super grateful, Thank You! I would like to let folks know until this notice disappears I'm doing a lot of breaking changes without worrying about them. Once versions are up around 0.5.x things should settle down.
- additionally, we're waiting on a PR before this package works properly without the AR/fix branch goofiness.
Flight Shield is an authentication and authorization plugin for FlightPHP, ported and adapted from CodeIgniter Shield. It integrates into the flight-school plugin lifecycle and provides session login, access token authentication, HMAC-SHA256 request signing, JWT verification, role-based groups and permissions, password validation, and a full set of route-level middlewares — all wired into FlightPHP with minimal configuration.
Features
- Session-based authentication (login, logout, remember me with token rotation)
- Access token authentication (Bearer tokens in the
Authorizationheader) - HMAC-SHA256 API authentication with AES-256-GCM encrypted secrets at rest and replay protection
- JWT authentication (requires
firebase/php-jwt) - Chain authentication — try multiple authenticators in order, first success wins
- Groups and permissions (role-based access control with wildcard support)
- Per-user direct permissions in addition to group-inherited permissions
- Email two-factor authentication (2FA) as an optional post-login action
- Email activation on registration as an optional post-register action
- Magic link login
- Password validators: composition (min/max length), nothing-personal, dictionary (65k common passwords), and pwned (Have I Been Pwned API)
- Automatic password rehashing when the cost parameters change
- Rate limiting on login, 2FA, and magic link endpoints (per-IP, database-backed)
- CSRF protection on all mutating auth routes via
enlivenapp/flight-csrf - Force-password-reset flow
- Login attempt recording (none, failures only, or all)
- CLI commands for managing users, groups, permissions, and HMAC keys
- Global helper functions
auth()anduser_id() - View overrides — bundle views can be replaced per-app
Requirements
- PHP 8.1+
flightphp/core^3.0enlivenapp/flight-school^0.2enlivenapp/flight-csrf^0.1enlivenapp/flight-settings^0.1firebase/php-jwt^6.0 (optional, required only for JWT authentication)
Installation
1. Install via Composer
composer require enlivenapp/flight-shield
2. Enable the plugin in your flight-school config
In app/config/config.php, add the plugin to the plugins array:
'plugins' => [ 'enlivenapp/flight-shield' => [], ],
On first run, Plugin::register() calls ensureAppConfig(), which inspects your config file and automatically appends hmac and jwt stub entries inside the shield plugin block if they are not already present. This happens transparently — you will see the entries in your config file after the first request.
Shield also uses Flight School's return-array config format in src/Config/Config.php. The defaults from that file are stored on $app under enlivenapp.flight-shield, and the route prefix is defined there with 'routePrepend' => 'auth'.
3. Run the migrations
Shield ships two PHP migration classes under src/Database/Migrations/. Run them via the enlivenapp/migrations runway command:
php runway migrate:single enlivenapp/flight-shield
This creates the following tables: users, auth_identities, auth_logins, auth_token_logins, auth_remember_tokens, auth_groups_users, auth_permissions_users, auth_groups, auth_permissions, auth_group_permissions.
4. Seed default groups and permissions
Default groups, permissions, and group-permission mappings are declared as seed data in Plugin::$seeds. These are applied automatically by enlivenapp/migrations when flight-school calls handleActivate() during plugin activation. There is no separate CLI step — enabling the plugin through the flight-school lifecycle is what fires the seeds.
The seeds populate:
auth_groups—superadmin,admin,userauth_permissions—admin.access,users.list,users.create,users.edit,users.delete,profile.editauth_group_permissions— superadmin gets*(all), admin gets the admin/user management permissions, user getsprofile.edit
Quick Start
Protect a route with session auth
use Enlivenapp\FlightShield\Middlewares\SessionAuthMiddleware; Flight::route('/dashboard', function () { $user = auth()->user(); echo 'Hello, ' . $user->username; })->addMiddleware(new SessionAuthMiddleware(Flight::app()));
Log a user in manually
$result = auth()->attempt(['email' => 'user@example.com', 'password' => 'secret']); if ($result->isOK()) { Flight::redirect('/dashboard'); } else { echo $result->reason(); }
Check a permission
$user = auth()->user(); if ($user->can('users.edit')) { // allowed } if ($user->inGroup('admin', 'superadmin')) { // allowed }
Get the current user ID anywhere
$id = user_id(); // returns int|string|null
How It Works
Architecture overview
Plugin::register() registers the Auth facade as a Flight service ($app->auth()). The Auth class delegates every call to the currently active AuthenticatorInterface instance, obtained from Authentication::factory(). The factory resolves the alias (session, tokens, hmac, jwt) against the authenticators map in config and returns a singleton per request.
The four authenticators are:
| Alias | Class | Transport |
|---|---|---|
session |
Session |
PHP session + optional remember-me cookie |
tokens |
AccessTokens |
Authorization: Bearer <token> header |
hmac |
HmacSha256 |
Authorization: HMAC-SHA256 <key>:<sig> + X-Request-Timestamp header |
jwt |
JWT |
Authorization: Bearer <jwt> header |
JWT is not enabled by default (it is commented out in the default authenticators map). Add it explicitly and install firebase/php-jwt to use it.
ChainAuthMiddleware iterates the authentication_chain list (default: ['session', 'tokens', 'hmac']) and stops at the first authenticator that reports loggedIn() === true. This lets a single route accept both browser sessions and API clients.
Authorization uses two parallel systems: groups (assigned via auth_groups_users) and direct permissions (assigned via auth_permissions_users). User::can() checks direct permissions first, then walks the user's groups and queries auth_group_permissions for each. Wildcard permissions (*, users.*) are supported.
Password validation is a pipeline of ValidatorInterface classes run in order. The first failure short-circuits the pipeline and returns an error.
Session Authentication
The Session authenticator stores the authenticated user's ID in $_SESSION[$field] (default key: user). On login, the session ID is regenerated. On logout, the session key and any pending action key are removed and the session is regenerated again.
Remember me
When session.allow_remembering is true (the default), calling auth('session')->getAuthenticator()->remember() after login stores a split-token cookie. The cookie holds a selector:validator pair. The selector is stored in plain text in auth_remember_tokens; the validator is stored as sha256(validator). On each request where no session exists, the remember cookie is checked:
- Locate the token by selector.
- Compare
sha256(cookie_validator)against the stored hash usinghash_equals(). - If a mismatch is detected (possible token theft), all remember tokens for that user are purged.
- On success, the old token is deleted, the user is logged in, and a new token is issued (token rotation).
Expired tokens are purged probabilistically (20% of successful remember-me logins) to avoid a separate cleanup job.
Post-login actions (2FA, email activation)
When actions.login is set to an action class (e.g. Email2FA), attempt() puts the session into a STATE_PENDING state by storing the user ID and action class name in the session. The user is not considered fully logged in until the action is completed. The routes at /auth/2fa and /auth/activate drive this flow.
Configuration
'session' => [ 'field' => 'user', // Session key 'allow_remembering' => true, 'remember_cookie_name' => 'remember', 'remember_length' => 30 * 86400, // 30 days ],
Access Token Authentication
The AccessTokens authenticator reads the Authorization header and strips the Bearer prefix. It hashes the raw token with SHA-256, looks it up in auth_identities (type = access_token), and checks expiry and unused_token_lifetime. On success it touches last_used_at.
Tokens are stored as identities in auth_identities. Use shield:user CLI or your own application code to generate them.
HMAC Authentication
The HmacSha256 authenticator authenticates API clients that sign each request rather than sending a reusable secret.
Request format
The client sends:
Authorization: HMAC-SHA256 <userKey>:<signature>
X-Request-Timestamp: <unix timestamp>
The signature is computed as:
HMAC-SHA256(secret, timestamp + "\n" + raw_request_body)
Verification
- Parse
userKeyandsignaturefrom the header. - Look up the identity by
userKeyinauth_identities(type =hmac_sha256). - Reject the request if the timestamp is more than 300 seconds from
time()(replay protection). - Decrypt the stored
secret2and recompute the HMAC. Compare withhash_equals(). - Check
unused_token_lifetime. - Touch
last_used_at.
HMAC secrets at rest
Secrets are stored in auth_identities.secret2 encrypted with AES-256-GCM. Encryption keys live in app/config/config.php under hmac.encryption_keys (never in the database). The active key is identified by hmac.encryption_current_key.
Use shield:hmac CLI commands to set up and rotate keys (see CLI Commands section).
JWT Authentication
The JWT authenticator is stateless. It reads Authorization: Bearer <token>, parses and verifies the JWT using JWTManager (which wraps firebase/php-jwt), extracts the sub claim as the user ID, and loads the user from the database.
JWT is not enabled by default. To enable it:
- Install the dependency:
composer require firebase/php-jwt ^6.0 - Add
jwtto theauthenticatorsmap in your plugin config:
'authenticators' => [ 'session' => \Enlivenapp\FlightShield\Authentication\Authenticators\Session::class, 'tokens' => \Enlivenapp\FlightShield\Authentication\Authenticators\AccessTokens::class, 'hmac' => \Enlivenapp\FlightShield\Authentication\Authenticators\HmacSha256::class, 'jwt' => \Enlivenapp\FlightShield\Authentication\Authenticators\JWT::class, ],
- Configure JWT keys (auto-injected as a stub by
ensureAppConfig(), fill in the values):
'jwt' => [ 'header' => 'Authorization', 'time_to_live' => 3600, 'default_claims' => ['iss' => 'https://example.com'], 'keys' => [ 'default' => [ ['kid' => '', 'alg' => 'HS256', 'secret' => 'your-256-bit-secret'], ], ], ],
To generate a token for a user, call generateToken() directly on the JWT authenticator instance:
$jwt = auth('jwt')->getAuthenticator()->generateToken($user);
Chain Authentication
ChainAuthMiddleware tries each authenticator in authentication_chain in order. The first one whose loggedIn() returns true grants access and records the active date. If all fail, the user is redirected to the configured login URL.
use Enlivenapp\FlightShield\Middlewares\ChainAuthMiddleware; Flight::route('/api/resource', function () { // accessible by session, Bearer token, or HMAC })->addMiddleware(new ChainAuthMiddleware(Flight::app()));
Default chain: ['session', 'tokens', 'hmac']. Override with authentication_chain in config.
Groups and Permissions
Data model
auth_groups— group definitions (alias,title,description)auth_permissions— permission definitions (alias,description)auth_group_permissions— maps group aliases to permission aliasesauth_groups_users— maps user IDs to group aliasesauth_permissions_users— maps user IDs to permission aliases (direct grants)
Permission resolution
User::can(string $permission) checks in this order:
- Direct user permissions in
auth_permissions_users - Group permissions from
auth_group_permissionsfor each of the user's groups
Wildcard * in a group's permission list matches every permission check. Partial wildcards like users.* match users.create, users.edit, etc.
Superadmin visibility
Superadmin users are hidden from non-superadmin callers by default. This prevents lower-privilege admins from viewing, editing, or deleting superadmin accounts.
The User model methods that support this:
findAllPaginated(int $page, int $perPage, bool $includeSuperadmins = false)— when$includeSuperadminsisfalse, users in thesuperadmingroup are filtered out of results.countAll(bool $includeSuperadmins = false)— same filtering for counts.findById(int $id, bool $includeSuperadmins = true)— when$includeSuperadminsisfalse, returnsnullif the target user is in thesuperadmingroup. Defaults totruefor backward compatibility with internal auth lookups.
Pass $currentUser->inGroup('superadmin') as the flag so superadmins see everyone and non-superadmins see only non-superadmin users.
Note: Filtering is done post-fetch in PHP (not at the query level) due to ActiveRecord limitations with table-qualified column names in WHERE clauses. For paginated results, this means a page may contain slightly fewer results than $perPage if superadmin users were in the batch. This is a negligible edge case in practice.
Default seeds
When the plugin is activated through flight-school's lifecycle, the following seed data is inserted:
Groups: superadmin, admin, user
Permissions: admin.access, users.list, users.create, users.edit, users.delete, profile.edit
Group-permission mappings:
superadmin→*(all permissions)admin→admin.access,users.list,users.create,users.edit,users.deleteuser→profile.edit
Working with groups and permissions in code
$user = auth()->user(); // Check group membership $user->inGroup('admin'); // true if in 'admin' $user->inGroup('admin', 'superadmin'); // true if in either // Check permission $user->can('users.edit'); // Modify (requires ORM) $orm = Flight::app()->orm(); $user->addGroup('editor', $orm); $user->removeGroup('editor', $orm); $user->addPermission('posts.create', $orm);
Password Validators
Validators run as a pipeline during registration and password changes. The default set is CompositionValidator, NothingPersonalValidator, DictionaryValidator. PwnedValidator is available but not enabled by default.
| Validator | What it checks |
|---|---|
CompositionValidator |
Min length (default 8) and max length (128), per NIST SP 800-63B |
NothingPersonalValidator |
Password must not contain or closely match the username, email address, or reversed username. Also checks similarity via similar_text() against max_similarity (default 50%) |
DictionaryValidator |
Password must not appear in the bundled 65,000-entry common-password list |
PwnedValidator |
Password must not appear in the Have I Been Pwned database (uses k-anonymity API, fails open if the API is unreachable) |
Configure in passwords:
'passwords' => [ 'algorithm' => PASSWORD_DEFAULT, // PASSWORD_BCRYPT or PASSWORD_ARGON2ID 'cost' => 12, 'memory_cost' => 65536, // Argon2 only 'time_cost' => 4, // Argon2 only 'threads' => 1, // Argon2 only 'min_length' => 8, 'max_similarity' => 50, 'validators' => [ \Enlivenapp\FlightShield\Passwords\CompositionValidator::class, \Enlivenapp\FlightShield\Passwords\NothingPersonalValidator::class, \Enlivenapp\FlightShield\Passwords\DictionaryValidator::class, // \Enlivenapp\FlightShield\Passwords\PwnedValidator::class, ], ],
Middlewares
Apply middlewares to routes or route groups using FlightPHP's ->addMiddleware().
SessionAuthMiddleware
Requires an active session login. Redirects to the configured login URL if not authenticated. Records the active date on success.
use Enlivenapp\FlightShield\Middlewares\SessionAuthMiddleware; Flight::route('/dashboard', function () { ... }) ->addMiddleware(new SessionAuthMiddleware(Flight::app()));
TokenAuthMiddleware
Authenticates via a Bearer access token in the Authorization header. Halts with a redirect on failure.
use Enlivenapp\FlightShield\Middlewares\TokenAuthMiddleware; Flight::route('/api/data', function () { ... }) ->addMiddleware(new TokenAuthMiddleware(Flight::app()));
HmacAuthMiddleware
Authenticates via HMAC-SHA256 request signing. Reads the Authorization header, X-Request-Timestamp header, and the raw request body. Redirects on failure.
use Enlivenapp\FlightShield\Middlewares\HmacAuthMiddleware; Flight::route('POST /api/webhook', function () { ... }) ->addMiddleware(new HmacAuthMiddleware(Flight::app()));
JWTAuthMiddleware
Authenticates via a JWT Bearer token. Requires firebase/php-jwt. Redirects on failure.
use Enlivenapp\FlightShield\Middlewares\JWTAuthMiddleware; Flight::route('/api/secure', function () { ... }) ->addMiddleware(new JWTAuthMiddleware(Flight::app()));
ChainAuthMiddleware
Tries each authenticator in authentication_chain in order. Grants access on the first success. Redirects to the login URL if all fail.
use Enlivenapp\FlightShield\Middlewares\ChainAuthMiddleware; Flight::route('/mixed', function () { ... }) ->addMiddleware(new ChainAuthMiddleware(Flight::app()));
GroupMiddleware
Requires the authenticated user to belong to at least one of the specified groups. Redirects to group_denied URL on failure, login URL if not authenticated.
use Enlivenapp\FlightShield\Middlewares\GroupMiddleware; Flight::group('/admin', function () { ... }, [ new GroupMiddleware(Flight::app(), 'admin', 'superadmin'), ]);
PermissionMiddleware
Requires the authenticated user to hold at least one of the specified permissions (checked via User::can()). Redirects to permission_denied URL on failure.
use Enlivenapp\FlightShield\Middlewares\PermissionMiddleware; Flight::route('/posts/create', function () { ... }) ->addMiddleware(new PermissionMiddleware(Flight::app(), 'posts.create'));
ForcePasswordResetMiddleware
If the authenticated user's requiresPasswordReset() returns true, redirects to the configured force_reset URL. Passes through silently if the user is not logged in.
use Enlivenapp\FlightShield\Middlewares\ForcePasswordResetMiddleware; Flight::route('/account', function () { ... }) ->addMiddleware(new ForcePasswordResetMiddleware(Flight::app()));
RateLimitMiddleware
Applied automatically to POST /auth/login, POST /auth/magic-link, POST /auth/2fa/verify, and POST /auth/2fa/resend. Can also be applied to custom routes.
Counts failed login attempts from the client IP across both auth_logins and auth_token_logins within the decay_minutes window. If max_attempts is reached and the most recent failure is still within the lockout_minutes window, the request is halted with HTTP 429 and a JSON error body.
use Enlivenapp\FlightShield\Middlewares\RateLimitMiddleware; Flight::route('POST /custom-auth', function () { ... }) ->addMiddleware(new RateLimitMiddleware(Flight::app()));
CLI Commands
Flight Shield uses the runway CLI provided by flightphp/runway.
shield
Displays available sub-commands.
php runway shield
shield:user
Manage users.
php runway shield:user create -n admin -e admin@example.com -g superadmin php runway shield:user list php runway shield:user activate -e user@example.com php runway shield:user deactivate -n username php runway shield:user delete -e user@example.com php runway shield:user password -n username php runway shield:user changename -n username --new-name newusername php runway shield:user changeemail -n username --new-email new@example.com php runway shield:user addgroup -n username -g admin php runway shield:user removegroup -n username -g admin
shield:group
Manage groups.
php runway shield:group list php runway shield:group info -a admin php runway shield:group create -a editor -t Editor -d "Content editors" php runway shield:group update -a editor -t "Senior Editor" php runway shield:group delete -a editor php runway shield:group permissions -a admin php runway shield:group addpermission -a editor -p posts.create php runway shield:group removepermission -a editor -p posts.create
Deleting a group also removes all group-permission mappings and all user memberships. Any user left with no groups after deletion is automatically reassigned to the user group.
shield:permission
Manage permissions.
php runway shield:permission list php runway shield:permission create -a posts.create -d "Create posts" php runway shield:permission update -a posts.create -d "Create blog posts" php runway shield:permission delete -a posts.create
shield:hmac
Manage HMAC encryption keys and token lifecycle.
# Initial setup php runway shield:hmac init # Key inspection php runway shield:hmac listkeys # Key rotation php runway shield:hmac addkey php runway shield:hmac reencrypt # migrate all secrets to the active key php runway shield:hmac removekey -k k1 # Bulk operations php runway shield:hmac encrypt # encrypt any unencrypted secrets php runway shield:hmac decrypt # remove encryption from all secrets php runway shield:hmac invalidateAll # immediately expire all HMAC tokens
Key rotation workflow:
listkeys → addkey → reencrypt → removekey -k <old-key-id>
Configuration
All options live inside the shield entry in app/config/config.php. The plugin merges these with its defaults, so you only need to include values you want to override.
'plugins' => [ 'enlivenapp/flight-shield' => [ // ... your overrides here ], ],
Full default configuration
| Key | Default | Description |
|---|---|---|
default_authenticator |
'session' |
Authenticator used when none is specified |
authentication_chain |
['session', 'tokens', 'hmac'] |
Order tried by ChainAuthMiddleware |
allow_registration |
true |
Allow new user self-registration |
allow_magic_link |
false |
Enable magic link login |
magic_link_lifetime |
3600 |
Magic link token TTL in seconds |
default_group |
'user' |
Group assigned to newly registered users |
record_login_attempt |
'all' |
Login attempt recording: 'none', 'failure', or 'all' |
record_active_date |
true |
Update last_active on every authenticated request |
unused_token_lifetime |
7776000 |
Access/HMAC token inactivity TTL in seconds (90 days) |
valid_login_fields |
['email'] |
Fields accepted as login identifier |
personal_fields |
[] |
Additional user fields checked by NothingPersonalValidator |
email_sender |
null |
Callback for outbound emails (see Email Setup) |
actions.login |
null |
Post-login action class (Email2FA::class or null) |
actions.register |
null |
Post-register action class (EmailActivator::class or null) |
token_header |
'Authorization' |
Header read by the AccessTokens authenticator |
hmac_header |
'Authorization' |
Header read by the HmacSha256 authenticator |
Session settings
'session' => [ 'field' => 'user', 'allow_remembering' => true, 'remember_cookie_name' => 'remember', 'remember_length' => 30 * 86400, // 30 days ],
Password settings
'passwords' => [ 'algorithm' => PASSWORD_DEFAULT, 'cost' => 12, 'memory_cost' => 65536, 'time_cost' => 4, 'threads' => 1, 'min_length' => 8, 'max_similarity' => 50, 'validators' => [ \Enlivenapp\FlightShield\Passwords\CompositionValidator::class, \Enlivenapp\FlightShield\Passwords\NothingPersonalValidator::class, \Enlivenapp\FlightShield\Passwords\DictionaryValidator::class, ], ],
HMAC settings
'hmac' => [ 'encryption_keys' => [], // populated by shield:hmac init/addkey 'encryption_current_key' => '', // alias of the active key 'encryption_cipher' => 'aes-256-gcm', 'secret2_storage_limit' => 255, ],
JWT settings
'jwt' => [ 'header' => 'Authorization', 'time_to_live' => 3600, 'default_claims' => ['iss' => ''], 'keys' => [ 'default' => [ ['kid' => '', 'alg' => 'HS256', 'secret' => ''], ], ], ],
Rate limiting settings
'rate_limiting' => [ 'enabled' => true, 'max_attempts' => 10, 'decay_minutes' => 30, 'lockout_minutes' => 30, ],
Redirect URLs
'redirects' => [ 'login' => '/auth/login', 'logout' => '/', 'after_login' => '/', 'after_register' => '/', 'after_logout' => '/auth/login', 'force_reset' => '/auth/reset-password', 'permission_denied' => '/auth/login', 'group_denied' => '/auth/login', ],
Routes
Shield registers the following routes under the prefix defined by the returned routePrepend value in src/Config/Config.php (default: auth).
| Method | Path | Notes |
|---|---|---|
| GET | /auth/login |
Show login form |
| POST | /auth/login |
Process login — CSRF + rate limit |
| GET | /auth/logout |
Log out |
| GET | /auth/register |
Show registration form |
| POST | /auth/register |
Process registration — CSRF |
| GET | /auth/magic-link |
Show magic link request form |
| POST | /auth/magic-link |
Send magic link email — CSRF + rate limit |
| GET | /auth/magic-link/verify |
Verify magic link token |
| GET | /auth/2fa |
Show 2FA verification page (sends code) |
| POST | /auth/2fa/verify |
Verify 2FA code — CSRF + rate limit |
| POST | /auth/2fa/resend |
Resend 2FA code — CSRF + rate limit |
| GET | /auth/activate |
Show activation page (sends email) |
| GET | /auth/activate/verify |
Verify email activation token |
Email Setup
Shield does not ship with a mailer. Provide a callback that accepts the recipient address, subject, and body:
'email_sender' => function (string $to, string $subject, string $body): void { mail($to, $subject, $body); },
The callback is invoked for 2FA codes, magic links, and email activation messages.
Views
Shield renders its own views by default. To override any view, create a matching file in your app:
app/views/enlivenapp/flight-shield/<view-name>.php
Overrideable views:
login.phpregister.phpmagic_link_login.phpmagic_link_message.php2fa_verify.phpactivate.php
Security Notes
- Passwords — hashed with
password_hash()usingPASSWORD_DEFAULT(bcrypt, cost 12). Configurable to Argon2 viaalgorithm,memory_cost,time_cost, andthreads. Passwords are automatically rehashed when cost parameters change. - HMAC secrets — stored encrypted with AES-256-GCM. Encryption keys live in
app/config/config.php, never in the database. - Token comparison — all token and hash comparisons use
hash_equals()to prevent timing attacks. - HMAC replay protection — requests with a timestamp more than 300 seconds from
time()are rejected. - Remember-me tokens — split-token scheme (selector stored plain, validator stored as SHA-256 hash). Mismatch purges all tokens for the user.
- CSRF — all mutating auth routes are protected by
CsrfMiddlewarefromenlivenapp/flight-csrf. - Rate limiting — failed login attempts are tracked per IP across
auth_loginsandauth_token_logins. Excessive failures result in HTTP 429. - Session fixation — the session ID is regenerated on login and logout.
License
MIT — see LICENSE.