Custom auth methods & challenges
Kirby 6 rebuilt authentication around two extension points: auth methods start a login, auth challenges confirm it. Both are now instance-based PHP classes that bring their own form for the Panel login view.
Methods and challenges
A login in Kirby 6 has up to two steps:
- An auth method starts the login. It either logs the user in directly or creates a challenge. The core methods are
password,code,password-reset,webauthn(passkeys) andbasic-auth. - An auth challenge confirms the login, e.g. with a one-time code or a passkey. The core challenges are
email,totpandwebauthn. Challenges are used as second factor after a password, for passwordless logins with thecodemethod and for password resets.
Plugins can add both. Kirby 5 already had the authChallenges extension, Kirby 6 adds authMethods and replaces the static challenge class with a new base class.
<?php
load([
'Acme\\Auth\\AccessKeyMethod' => 'classes/AccessKeyMethod.php',
'Acme\\Auth\\SmsChallenge' => 'classes/SmsChallenge.php'
], __DIR__);
Kirby::plugin('acme/auth', [
'authMethods' => [
'access-key' => Acme\Auth\AccessKeyMethod::class
],
'authChallenges' => [
'sms' => Acme\Auth\SmsChallenge::class
]
]);
Registering a method or challenge doesn't enable it. Site developers add it to their config, in the position they want:
return [
'auth' => [
'methods' => ['password', 'access-key'],
'challenges' => ['webauthn', 'totp', 'sms', 'email']
]
];
The key you register must match the type() of your class. By default, Kirby derives it from the class name: AccessKeyMethod becomes access-key, SmsChallenge becomes sms. Name your class accordingly or override type().
Auth methods
Auth methods extend the abstract Kirby\Auth\Method class:
| Method | Description | |
|---|---|---|
authenticate() |
required | Logs the user in and returns the Kirby\Cms\User, or creates a challenge and returns the pending Kirby\Auth\Status |
form() |
needed for the Panel | The Vue component (and its props) for the login view |
static icon() |
optional | Icon in the method picker, default: key |
static isEnabled() |
optional | Whether the method can be used on this site, default: true |
static settings() |
optional | Kirby\Panel\Ui\Button objects for the "Sign in methods" section of the user's security drawer |
static type() |
optional | Identifier, derived from the class name |
A method has two ways to finish:
- Log the user in with
$user->loginPasswordless()and return the user. - Create a challenge with
$this->auth->createChallenge()and return theStatusit gives you. The Panel then shows the challenge form. This is what the corecodemethod does.
Example: log in with an access key
Methods don't need an email address. This method logs users in with a personal access key in the format <user id>.<secret> and finds the user from the key itself:
<?php
namespace Acme\Auth;
use Kirby\Auth\Exception\LoginNotPermittedException;
use Kirby\Auth\Method;
use Kirby\Cms\User;
use Kirby\Exception\PermissionException;
use Kirby\Panel\Ui\Component;
use SensitiveParameter;
class AccessKeyMethod extends Method
{
public function authenticate(
string|null $email,
#[SensitiveParameter]
string|null $password = null,
bool $long = false
): User {
return $this->auth->guard(
// no email: the rate limit only applies to the IP address
email: null,
attempt: function () use ($password, $long) {
[$id, $secret] = explode('.', $password ?? '', 2) + ['', ''];
$user = $this->auth->kirby()->users()->find($id);
$hash = $user?->secret('accessKey');
if (
is_string($hash) === false ||
password_verify($secret, $hash) === false
) {
throw new PermissionException(message: 'Invalid access key');
}
$user->loginPasswordless([
'createMode' => 'cookie',
'long' => $long
]);
return $user;
},
// shown instead of the real error when auth debugging is off
fallback: new LoginNotPermittedException()
);
}
public function form(): Component
{
return new Component(
component: 'k-login-access-key-method-form',
submit: [
'icon' => static::icon(),
'label' => static::i18n('login'),
],
);
}
}
How users get their key is up to your plugin, e.g. a drawer linked from settings() that stores a hash in the user's secrets with $user->changeSecret('accessKey', password_hash($secret, PASSWORD_DEFAULT)).
The form is a regular Panel component:
<template>
<form class="k-login-form" @submit.prevent="onSubmit">
<k-password-field
:autofocus="true"
:counter="false"
:label="$t('login.method.access-key.field')"
:required="true"
:value="accessKey"
name="accessKey"
@input="accessKey = $event"
/>
<k-login-footer>
<k-login-remember :checked="long" @input="long = $event" />
<k-login-submit :icon="submit.icon" :label="submit.label" />
</k-login-footer>
</form>
</template>
<script>
export default {
props: {
submit: Object
},
emits: ["submit"],
data() {
return {
accessKey: "",
long: false
};
},
methods: {
onSubmit() {
// `email`, `password` and `long` are passed to `authenticate()`
this.$emit("submit", { password: this.accessKey, long: this.long });
}
}
};
</script>
import LoginAccessKeyMethodForm from "./components/LoginAccessKeyMethodForm.vue";
import LoginSmsChallengeForm from "./components/LoginSmsChallengeForm.vue";
panel.plugin("acme/auth", {
components: {
"k-login-access-key-method-form": LoginAccessKeyMethodForm,
"k-login-sms-challenge-form": LoginSmsChallengeForm
}
});
Building Panel plugins with Vue 3 is explained in Vue 3 for Panel plugins.
Auth challenges
Auth challenges extend the abstract Kirby\Auth\Challenge class. Unlike in Kirby 5, a challenge is an object: Kirby creates one for a specific user and purpose ($mode: 'login', 'password-reset' or '2fa').
| Method | Description | |
|---|---|---|
create() |
required | Generates and sends the code (or whatever your challenge needs) and returns the data to verify it later. Return null if there's nothing to remember, e.g. for codes generated by an app. |
verify() |
required | Checks the user's input against the data from create() |
form() |
needed for the Panel | The Vue component (and its props) for the login view |
static icon() |
optional | Icon in the challenge switcher, default: key |
static isAvailable() |
optional | Whether the user can use this challenge for this purpose, default: true |
static isEnabled() |
optional | Whether the challenge can be used on this site at all, default: true |
isSingleUse() |
optional | Return true if a failed attempt must discard the pending challenge (e.g. a signed nonce), default: false |
static settings() |
optional | Kirby\Panel\Ui\Button objects for the "Two-factor methods" section of the user's security drawer |
type(): string |
optional | Identifier, derived from the class name |
The Pending object
create() returns a Kirby\Auth\Pending object with two optional slots:
use Kirby\Auth\Pending;
return new Pending(
// sent to the Panel, e.g. a masked phone number
public: '•••• 1234',
// stays in the session, only passed to verify()
secret: password_hash($code, PASSWORD_DEFAULT)
);
Both end up in the session, so only use strings, numbers and arrays. You get the same object back in form() and verify(). Kirby 5 hashed the code for you. In Kirby 6, hashing is your job: never put a plain code into secret.
Kirby checks the timeout before it calls verify() and rate-limits every attempt. A wrong code can be retried until the challenge times out, unless isSingleUse() returns true.
Availability means "set up"
In Kirby 6, every user can opt into a second factor (see two-factor for every user). Kirby considers a second factor as set up when isAvailable($user, '2fa') returns true. Such users are asked for it after every password login, even if the site doesn't set '2fa' => true.
If your challenge is available for every user (e.g. everybody with a phone number), make the 2fa mode opt-in: store a flag in the user's secrets once the user has confirmed the factor, and check it in isAvailable(). The core email challenge does exactly that with $user->secret('email'). Add a settings() button so users can turn the factor on and off in their security drawer.
For the login and password-reset modes, Kirby only offers challenges that the user has also set up as second factor (if they have any), so a password reset can never be weaker than the user's 2FA setup.
How the login view renders your form
The Panel login view is driven by the backend. Each step has its own route:
/panel/login/method/{type}renders theform()of the method,/panel/login/challenge/{type}renders theform()of the active challenge.
form() returns a Kirby\Panel\Ui\Component. Its first argument is the name of a Vue component, all other named arguments become its props. Your component receives these props and talks to the view through two events:
submitwith the form data. The view sends it to the backend. For methods, Kirby readsemail,passwordandlongand callsAuth::authenticate($type, $email, $password, $long): put any secret your method needs intopassword(the passkey method sends its JSON-encoded assertion there). For challenges, Kirby readscodeand passes it toverify(). It can be anything JSON can carry.errorwith an object that has amessage. The view shows it above the form.
After a successful method, the view either opens the Panel or moves on to the challenge. The method picker and the challenge switcher below the form are rendered by the view, with the icon() of each class and the translation login.method.{type}.label or login.challenge.{type}.label as text. Add these translations to your plugin.
You can build your form from the same pieces as the core forms:
| Component | |
|---|---|
<k-login-footer> |
Footer row, with a "Back" button if you pass :back="true" (for challenges) |
<k-login-submit> |
Submit button with icon, label and loading props |
<k-login-remember> |
"Keep me logged in" checkbox for methods |
<k-login-code> |
One-time code field. With type="sms", it uses the translations login.code.label.sms, login.code.text.sms and login.code.placeholder.sms. |
<k-login-back> |
The "Back" button on its own |
Add the class k-login-form to your <form> to get the same box as the core forms. For more examples, look at the core forms in panel/src/components/Views/Login/ of the Kirby source, e.g. LoginEmailChallengeForm.vue, and their PHP counterparts in src/Auth/Method/ and src/Auth/Challenge/.
Entries in the security drawer
Your method or challenge can show up in the user's security drawer, right next to the core entries. That's where users set up and manage it, e.g. enter the phone number for an SMS challenge or create their access key.
Return one or more Kirby\Panel\Ui\Button objects from the static settings() method. Kirby calls it for every enabled method and challenge: buttons of methods appear under Sign in methods, buttons of challenges under Two-factor methods.
use Kirby\Cms\User;
use Kirby\Panel\Ui\Button;
class SmsChallenge extends Challenge
{
// …
public static function settings(User $user): array
{
return [
new Button(
icon: static::icon(),
text: static::i18n('login.challenge.sms.label'),
dialog: $user->panel()->url(true) . '/security/challenge/sms'
)
];
}
}
The button opens a dialog or drawer that your plugin registers itself. $user->panel()->url(true) points to /account for the logged-in user and to /users/(id) for everyone else, so register your route in both areas, like the core does for its own entries:
usersarea:users/(:any)/security/challenge/smsaccountarea:(account)/security/challenge/sms
Both patterns pass the user ID (or account) to your route, and Kirby\Cms\Find::user($id) returns the right user for either. Read Panel backend: areas, controllers & UI classes for how to define dialogs and drawers in Kirby 6.
Without a settings() method, your method or challenge works at login, but doesn't show up in the security drawer.
Migrating a Kirby 5 challenge
A custom challenge for Kirby 5 extends Kirby\Cms\Auth\Challenge. This class no longer exists, so the plugin breaks with a "class not found" error until you update it. Here is an SMS challenge before and after.
Before: Kirby 5
<?php
namespace Acme\Auth;
use Kirby\Cms\Auth\Challenge;
use Kirby\Cms\User;
use Kirby\Toolkit\Str;
class SmsChallenge extends Challenge
{
public static function isAvailable(User $user, string $mode): bool
{
return $user->content()->get('phone')->isNotEmpty();
}
public static function create(User $user, array $options): string
{
$code = Str::random(6, 'num');
// your SMS provider's client
Gateway::send(
$user->content()->get('phone')->value(),
'Your login code: ' . $code
);
// Kirby hashed the returned code and stored it in the session
return $code;
}
// no verify(): the base class compared the input
// with the hashed code from the session
}
Kirby::plugin('acme/auth', [
'authChallenges' => [
'sms' => Acme\Auth\SmsChallenge::class
],
'translations' => [
'en' => [
'login.code.placeholder.sms' => '000000',
'login.code.text.sms' => 'We sent a code to your phone.'
]
]
]);
After: Kirby 6
<?php
namespace Acme\Auth;
use Kirby\Auth\Challenge;
use Kirby\Auth\Pending;
use Kirby\Cms\User;
use Kirby\Panel\Ui\Component;
use Kirby\Toolkit\Str;
use SensitiveParameter;
class SmsChallenge extends Challenge
{
public static function isAvailable(User $user, string $mode): bool
{
return $user->content()->get('phone')->isNotEmpty();
}
public function create(): Pending
{
$code = Str::random(6, 'num');
$phone = $this->user->content()->get('phone')->value();
// your SMS provider's client
Gateway::send($phone, 'Your login code: ' . $code);
return new Pending(
public: '•••• ' . substr($phone, -4),
secret: password_hash($code, PASSWORD_DEFAULT)
);
}
public function form(Pending $pending): Component
{
return new Component(
component: 'k-login-sms-challenge-form',
submit: $this->submit(),
phone: $pending->public(),
user: $this->user->email(),
);
}
public static function icon(): string
{
return 'mobile';
}
public function verify(
#[SensitiveParameter]
mixed $input,
Pending $data
): bool {
$hash = $data->secret();
if (is_string($input) === false || is_string($hash) === false) {
return false;
}
return password_verify(str_replace(' ', '', $input), $hash);
}
}
With isAvailable() as above, every user with a phone number is asked for an SMS code after their password. Make the 2fa mode opt-in as described in Availability means "set up" if that's not what you want.
Kirby::plugin('acme/auth', [
'authChallenges' => [
'sms' => Acme\Auth\SmsChallenge::class
],
'translations' => [
'en' => [
'login.challenge.sms.label' => 'Code via SMS',
'login.code.label.sms' => 'Code from SMS',
'login.code.placeholder.sms' => '000000',
'login.code.text.sms' => 'We sent a code to { phone }.'
]
]
]);
Kirby 5 rendered the same generic code form for every challenge. In Kirby 6, the challenge brings its own:
<template>
<form class="k-login-form" @submit.prevent="onSubmit">
<k-user-info :user="user" />
<k-text-field
:autofocus="true"
:counter="false"
:help="$t('login.code.text.sms', { phone })"
:label="$t('login.code.label.sms')"
:placeholder="$t('login.code.placeholder.sms')"
:required="true"
:value="code"
autocomplete="one-time-code"
icon="unlock"
name="code"
@input="code = $event"
/>
<k-login-footer :back="true">
<k-login-submit :icon="submit.icon" :label="submit.label" />
</k-login-footer>
</form>
</template>
<script>
export default {
props: {
phone: String,
submit: Object,
user: String
},
emits: ["submit"],
data() {
return {
code: ""
};
},
methods: {
onSubmit() {
this.$emit("submit", { code: this.code });
}
}
};
</script>
If you don't need the phone number in the form, <k-login-code type="sms" :value="code" @input="code = $event" /> replaces the whole <k-text-field>.
Replacing the login form
In Kirby 5, a plugin could replace the whole login form with the login extension of panel.plugin(). This extension has been removed in Kirby 6, together with the <k-login-form> and <k-login-code-form> components and the deprecated aliases <k-login> and <k-login-code>.
Write an auth method instead and return your form from its form() method. Your method then appears next to the core methods, and site developers decide in auth.methods whether it's the only one, the first one or an alternative.
Rate limiting with Auth::guard()
$kirby->auth()->guard() wraps any login attempt in Kirby's brute-force protection:
$user = $kirby->auth()->guard(
email: $email, // or null to only limit by IP address
attempt: fn () => $this->checkCredentials(),
fallback: new LoginNotPermittedException()
);
- It checks whether the IP address (and the email address, if you pass one) has used up its
auth.trialswithinauth.timeout. If so, the attempt fails with aRateLimitExceptionwithout running$attempt. - It runs
$attemptand returns its result. - If anything fails, it counts the failed attempt (except for rate limit hits), which also triggers the
user.login:failedhook. It then waits a random amount of time and throws$fallbackinstead of the real error. The real error only comes through with'auth.debug' => true. By default ('auth.debug' => 'log'), it is written to the PHP error log. Without a$fallback,guard()returnsnullin that case.
Use it in every custom method or route that checks credentials, so attackers can't tell why an attempt failed or brute-force your method. Before a method's authenticate() runs, Kirby already rejects IP addresses that are blocked.
Find all other changes to the auth system in the changelog and the migration guide for plugin developers.