Foundational authentication primitives for Craft CMS 5: passwordless tokens (magic links + email OTP), passkey wrappers, a recent-auth gate, a password-validator contract, and an audit-event contract. The shared base for the CraftPulse security ecosystem.
Auth Kit is primitives + contracts. It ships no routes, controllers, or
UX. Consuming plugins (Warden,
Warp, a Password Policy adapter) own those and call into Auth Kit's services.
It is a free, foundational plugin rather than a bare library because the token
store needs a table and migrations: a single installed plugin owns the
authkit_* schema once, so consumers never collide on it.
- Craft CMS 5.10.0 or later (the passkey wrappers use core's WebAuthn serializer, which is only public as of 5.10.0)
- PHP 8.2 or later
composer require craftpulse/craft-auth-kit
./craft plugin/install auth-kitMost of the time you won't install Auth Kit directly; it is pulled in as a Composer dependency of the plugin that uses it.
Issue and consume hashed, single-use, TTL'd passwordless credentials. The raw secret is never persisted (only its SHA-256 hash); consumption burns the token atomically; issuance is enumeration- and timing-safe.
use craftpulse\authkit\AuthKit;
$tokens = AuthKit::$plugin->tokens;
// Magic links: a 32-byte secret emailed as a verify URL, no attempt cap.
$tokens->issueMagicLink($email, $returnUrl); // bool, respond identically regardless
$user = $tokens->consumeMagicLink($rawToken); // ?User
// Email OTP: short numeric code, attempt-capped, superseded on re-issue.
$tokens->issueOtp($email); // bool
$user = $tokens->consumeOtp($email, $code); // ?User
// Guest OTP: short numeric code bound to an ARBITRARY mailbox, not a user.
// Proves inbox control for any valid email (member or external); the recipient
// never becomes a user, is never logged in, and gets no session.
$tokens->issueGuestOtp($email, 'my-plugin'); // void, never reveals if sent
$proved = $tokens->consumeGuestOtp($email, $code, 'my-plugin'); // bool
// Registration: a 32-byte secret for an address with no account yet.
// Mints no user row; the email lives in the token payload until verify time.
$tokens->issueRegistration($email, $returnUrl); // bool, unknown address only
$token = $tokens->consumeRegistration($rawToken); // ?Token, payload carries the email
// Maintenance: prune expired rows (safe on a schedule).
$tokens->purgeExpiredTokens(); // int rows deletedRegistration is the inverse of a login issuance: issueRegistration() proceeds
only for an address with no account yet, and refuses (silently, timing-equalized)
any address that already maps to a user of any status. The two branches a unified
sign-in/sign-up endpoint dispatches between (existing address to issueMagicLink(),
unknown address to issueRegistration()) stay indistinguishable by timing.
consumeRegistration() proves only mailbox possession and returns the burned
token; the consuming plugin owns the account decision (create, activate, log in)
from the payload's email.
The guest OTP is the user-less sibling of issueOtp(): it issues to any
syntactically valid email whether or not it maps to a Craft user, and
consumeGuestOtp() returns a plain bool for whether the code proved control of
the mailbox. Nothing is logged in and no session is created, because a guest
verification is not an authentication, so it emits no AuthEvent, and the raw
email is never stored (the token carries only the SHA-256 of the lowercased
address). Attribution is the consuming plugin's audit story. Its origin is a
required argument (not an option), since a guest code only makes sense scoped to
the consumer that issued it.
Tunable as service properties (no settings model, so set them on the component, e.g.
via config/app.php): tokenTtl (default 900s), otpDigits (6),
otpMaxAttempts (5), perEmailLimit (5), perEmailWindow (300s),
magicLinkRoute, and registrationRoute, the site routes your plugin
registers for the verify URLs (Auth Kit imposes no URLs).
Important
issueMagicLink() / issueOtp() / issueRegistration() return whether a
credential was actually sent, but any public-facing caller must respond
identically whether or not the address exists. That is what keeps the
endpoint enumeration-safe. Per-IP rate limiting belongs on your controller
(core's RateLimiter); the per-address throttle here covers every channel
including programmatic use.
Thin wrappers over core's WebAuthn machinery for front-end users, plus the recent-auth gate (the passwordless replacement for elevated sessions; stamped automatically on login).
$passkeys = AuthKit::$plugin->passkeys;
$passkeys->getCreationOptions($user); // string (JSON) for the browser
$passkeys->verifyCreation($credentials, $name); // bool
$passkeys->getPasskeys($user); // array
$passkeys->hasPasskeys($user); // bool
$passkeys->deletePasskey($user, $uid);
$passkeys->hasRecentAuth($within); // bool, gate sensitive actions
$passkeys->stampRecentAuth();Important
The credential-changing operations enforce the gate themselves:
verifyCreation() and deletePasskey() throw a
yii\web\ForbiddenHttpException when the session has not authenticated
within the recent-auth window. Check hasRecentAuth() in your controller
first for a friendly response. The gate authenticates the session, not the
target: always pass the authenticated user's own element, never a user
resolved from request input.
The neutral cooperation seam for password strength and breach checks. Plugins
cooperate through this contract and a registry, never by sniffing each
other with isPluginInstalled().
$result = AuthKit::$plugin->passwords->validate($password, $user);
if (!$result->isValid) {
// surface $result errors
}A provider (e.g. Password Policy) registers a validator implementing
craftpulse\authkit\passwords\PasswordValidatorInterface:
use craftpulse\authkit\services\Passwords;
use craftpulse\authkit\events\RegisterPasswordValidatorsEvent;
use yii\base\Event;
Event::on(
Passwords::class,
Passwords::EVENT_REGISTER_PASSWORD_VALIDATORS,
function(RegisterPasswordValidatorsEvent $event) {
$event->validators[] = new MyPolicyValidator();
}
);If no validator is registered, validate() is a graceful no-op (valid). The
interface is deliberately tiny and stable, so treat any change to it as a major
version bump.
The neutral cooperation seam for authentication audit logging. Emitters describe
an auth fact with the AuthEvent value object and hand it to record();
provider plugins register sinks that persist or forward it. Plugins cooperate
through this contract and a registry, never by sniffing each other with
isPluginInstalled().
An emitter (Warden, Warp) records an event:
use craftpulse\authkit\audit\AuthEvent;
AuthKit::$plugin->audit->record(new AuthEvent(
name: AuthEvent::LOGIN_SSO,
emitter: 'warden',
userId: $user->id,
details: ['provider' => $providerHandle], // scalar-only, no PII
));A provider (e.g. Password Policy) registers a sink implementing
craftpulse\authkit\audit\AuditSinkInterface:
use craftpulse\authkit\audit\AuditSinkInterface;
use craftpulse\authkit\audit\AuthEvent;
use craftpulse\authkit\events\RegisterAuditSinksEvent;
use craftpulse\authkit\services\Audit;
use yii\base\Event;
Event::on(
Audit::class,
Audit::EVENT_REGISTER_AUDIT_SINKS,
function(RegisterAuditSinksEvent $event) {
$event->sinks[] = new class implements AuditSinkInterface {
public function handle(AuthEvent $event): void
{
// Persist or forward. Ignore names you don't recognize.
}
};
}
);record() fans the event out to every registered sink in order, wrapping each
in its own try/catch: a sink that throws is logged and skipped, never blocking
the auth flow nor the sinks after it. With no sink registered, record() is a
cheap no-op.
The contract has three rules:
detailsis scalar-only and carries no PII. No emails, raw IPs, or raw user agents. Onlybool,int,float, andstringvalues are accepted; a non-scalar value throws at construction. (outcomeis likewise validated: it must beOUTCOME_SUCCESSorOUTCOME_FAILURE.)- Sinks ignore unknown event names silently. Auth Kit adds names in minor releases, so a sink may receive a name newer than the vocabulary it was written against, and it must not error on one.
- Emitters never edition-gate emission. Emit unconditionally; the sink side decides what to keep.
The AuthEvent shape is frozen at 1.2.0, so treat any change to its properties or
constructor as a major version bump. New event-name constants, by contrast, are
additive and ship in minors.
A second, independent seam on the same record() call, added in 1.5.0. Where
the sink registry above is Auth Kit's own cooperation contract (a provider
registers a AuditSinkInterface directly on Auth Kit's Audit component),
EVENT_AFTER_RECORD lets a downstream observer relay the event elsewhere
without ever registering as a sink at all. It exists chiefly so
Audit Kit's bridge can relay
every recorded AuthEvent onto its own neutral audit bus.
record() fires it once, after the sink fan-out has run to completion:
use craftpulse\authkit\events\AuditRecordEvent;
use craftpulse\authkit\services\Audit;
use yii\base\Event;
Event::on(
Audit::class,
Audit::EVENT_AFTER_RECORD,
function(AuditRecordEvent $event) {
// $event->event is the same AuthEvent record() was called with.
}
);Three things to know about this seam:
- It is independent of the sink registry. The sink loop and the
EVENT_AFTER_RECORDtrigger are two separate, unconditional statements inrecord()with no shared guard. Muting the sinks (setSinks([])) does not mute this event, and attaching a listener here does not add a sink. Neither surface supersedes the other, and neither is deprecated. - It fires unconditionally, gated only by whether a listener is attached. With no listener it is a cheap no-op, exactly like an empty sink registry.
- It carries the same frozen
AuthEventon a small, read-only wrapper (AuditRecordEvent::$event). It never lets a listener mutate the fan-out that already happened.
A provider plugin can use either seam, or both, for entirely different
downstream stores. Password Policy, for example, registers a sink (surface
one) to land events on its own hash-chained audit log, while Audit Kit's
bridge listens on EVENT_AFTER_RECORD (surface two) to feed its own bus. Both
run on every record() call, independently of each other.
Testing note: a suite that stubs out audit recording must neutralize
both surfaces. Clearing the sink registry (Audit::setSinks([])) has no
effect on an EVENT_AFTER_RECORD listener already attached elsewhere (for
example, by a co-installed Audit Kit); detach that listener too, or the test
will still relay a real event onto Audit Kit's bus.
A craft.authKit Twig variable exposes hasPasskeys, passkeys, and
webauthnJsUrl for templates, and Auth Kit publishes a shared
authkit-webauthn.js browser client. Default auth_kit_magic_link,
auth_kit_otp, and auth_kit_register system messages ship out of the box;
consumers override them.
| Service | Event | Fired |
|---|---|---|
tokens |
EVENT_AFTER_ISSUE_TOKEN |
after a token is issued |
tokens |
EVENT_BEFORE_CONSUME_TOKEN |
before consume (cancelable: refuses login, leaves the token unburned) |
tokens |
EVENT_AFTER_CONSUME_TOKEN |
after consume, user resolved |
passwords |
EVENT_REGISTER_PASSWORD_VALIDATORS |
to register password validators |
audit |
EVENT_REGISTER_AUDIT_SINKS |
to register audit sinks |
audit |
EVENT_AFTER_RECORD |
after every record() call, independently of the sink registry (the Audit Kit bridge point) |
| Plugin | Uses Auth Kit for |
|---|---|
| Warden | Lite passwordless: magic links, passkeys, recent-auth |
| Warp (planned) | the whole passwordless product surface |
| Password Policy (planned adapter) | provides a PasswordValidatorInterface adapter |
MIT, © CraftPulse