Account Store
The AccountStore contract, its twelve optional capabilities, the Lucid default, and the model mixins.
The AccountStore is AuthKit's primary identity contract. defineConfig derives
findAccount and verifyCredentials from it, and the host-kit controllers use it for
signup, password reset, email verification, and MFA. AuthKit never sees your Lucid model
directly — only the AuthAccount DTO the store returns.
The contract
The store is a mandatory core plus a set of optional capabilities. The
AccountStore type used by the config is the core intersected with a Partial<> of every
capability, so a store that implements nothing but the core still type-checks as an
AccountStore.
interface AuthAccount {
id: string
email: string
globalRoles?: string[]
name?: string
avatarUrl?: string
}
// Always present. Identity, signup, password reset, email verification, and admin.
interface CoreAccountStore extends AdminCapability {
// Provider-facing
findById(id: string): Promise<AuthAccount | null>
verifyCredentials(email: string, password: string): Promise<AuthAccount | null>
// Signup / social
findByEmail(email: string): Promise<AuthAccount | null>
create(input: CreateAccountInput): Promise<AuthAccount>
// Password reset
issuePasswordResetToken(email: string): Promise<{ token: string; account: AuthAccount } | null>
consumePasswordResetToken(token: string, newPassword: string): Promise<boolean>
// Email verification (writes the state)
issueEmailVerificationToken(email: string): Promise<{ token: string; account: AuthAccount } | null>
consumeEmailVerificationToken(token: string): Promise<boolean>
}
// Folded into the core: the admin console and the Admin REST API call these directly.
interface AdminCapability {
listAccounts(params: ListAccountsParams): Promise<Paginated<AuthAccount>>
setGlobalRoles(accountId: string, roles: string[]): Promise<void>
/** Optional fast path — see below. */
countByGlobalRole?(role: string): Promise<number>
}countByGlobalRole is the one optional member of the core. AuthKit cannot write a generic
count query for it, because the shape of globalRoles is yours — a JSON column, a join
table, a claim from somewhere else. Implement it if you know how to count efficiently in
your own schema (the "last admin" invariant is the caller that asks); leave it out and the
caller falls back to paging through listAccounts.
Everything else is a capability: a named interface that a store either implements in full or omits entirely.
type AccountStore = CoreAccountStore &
Partial<
MfaCapability & WebauthnCapability & ProviderIdentityCapability &
AccountSecurityCapability & AccountStatusCapability & ProfileCapability &
MagicLinkCapability & OtpLoginCapability & EmailVerificationStatusCapability &
AccountDeletionCapability & AccountImportCapability & OrganizationsCapability
>A capability is all-or-nothing: a store either implements every method of
MfaCapability or omits the capability completely — there are no half-present methods that
throw. That rule is what makes the whole design work, because AuthKit decides at runtime
which features exist by probing for a method, not by reading a flag you set.
The capability probe
Every optional capability ships an exported type guard. Each guard checks one representative method and narrows the store type on the true branch:
import { supportsMfa } from '@adonis-agora/authkit-server'
if (supportsMfa(store)) {
await store.getMfaState(accountId) // typed as AccountStore & MfaCapability here
}This is why a missing capability must mean missing methods rather than methods that
throw. If you stub getMfaState with a placeholder that throws, supportsMfa returns
true, AuthKit renders the MFA challenge, and the flow breaks at request time instead of
degrading quietly.
Degradation is the point. When a capability is absent AuthKit does not error — it removes the feature: the UI hides the section, the flow skips the step, and the REST/admin surfaces answer with an explicit "unsupported" status. The table below is the full map from guard to feature; each capability is detailed underneath.
| Guard | Capability | Probes | Lights up | With lucidAccountStore |
|---|---|---|---|---|
supportsMfa | MfaCapability | getMfaState | TOTP second factor at login and enrollment on /account/security | always |
supportsPasskeys | WebauthnCapability | listPasskeys | Passkey registration and passkey login | needs webauthnCredentialModel |
supportsProviderIdentity | ProviderIdentityCapability | findByProviderIdentity | Social account linking, plus identity cascade on delete/export | needs providerIdentityModel |
supportsAccountSecurity | AccountSecurityCapability | changePassword | Change password / change email on /account/security | always |
supportsAccountStatus | AccountStatusCapability | disableAccount | Admin Disable / Enable; both login paths reject disabled accounts | needs a disabled_at column |
supportsProfile | ProfileCapability | updateProfile | The Profile section (name / avatar) | needs full_name and/or avatar_url |
supportsMagicLink | MagicLinkCapability | issueMagicLinkToken | Passwordless "email me a link" login | always |
supportsOtpLogin | OtpLoginCapability | issueMagicLinkWithCode + verifyLoginCode | The typable one-time code that rides along with the magic link | always |
supportsEmailVerificationStatus | EmailVerificationStatusCapability | isEmailVerified | The login.requireVerifiedEmail gate | needs an email_verified_at column |
supportsAccountDeletion | AccountDeletionCapability | deleteAccount | The account danger zone, DELETE /users/:id, and the LGPD/GDPR cascade | always |
supportsAccountImport | AccountImportCapability | importAccount | The authkit:users:import command | always |
supportsOrganizations | OrganizationsCapability | createOrg | Multi-tenancy: orgs, members, invitations, org claims | needs organizationModels |
"always" means the Lucid store mounts the capability for any model composed from the
AuthKit mixins — no extra models, no extra columns, no configuration. The three
column-probed rows are detected through Model.$columnsDefinitions, so adding the column
in your own migration is all it takes.
The capabilities in detail
The Lucid default
lucidAccountStore(Model, options?) implements the contract against a Lucid model:
import AuthUser from '#models/auth_user'
import { lucidAccountStore } from '@adonis-agora/authkit-server'
accountStore: lucidAccountStore(AuthUser, { mfaIssuer: 'Acme' })The mfaIssuer option is the label shown in authenticator apps; the store uses it to build
the TOTP otpauth URI.
The store is assembled per capability. The core, MFA, account security, magic link, OTP
login, account import and account deletion are mounted for every model. The rest are
conditional, and when a condition is not met the methods are genuinely absent from the
returned object — which is precisely what makes the supports* guards tell the truth:
| Capability | Mounted when |
|---|---|
WebauthnCapability | you pass webauthnCredentialModel |
ProviderIdentityCapability | you pass providerIdentityModel |
OrganizationsCapability | you pass all three organizationModels |
AccountStatusCapability | the model has a disabled_at column |
ProfileCapability | the model has full_name and/or avatar_url |
EmailVerificationStatusCapability | the model has an email_verified_at column |
Columns are probed through Model.$columnsDefinitions, so you add them with your own
migration and the capability appears on the next boot. Nothing else changes.
lucidStores — the same wiring, declared once
lucidStores builds the account store plus the PAT store and audit sink from one map of
models, so mfaIssuer / webauthn / encrypter are declared a single time:
import { lucidStores } from '@adonis-agora/authkit-server'
const { accountStore, patStore, audit } = lucidStores(
{
account: AuthUser,
pat: PersonalAccessToken,
audit: AuditLog,
providerIdentity: ProviderIdentity,
webauthnCredential: WebauthnCredential,
organizations: {
OrgModel: Organization,
MemberModel: OrganizationMember,
InvitationModel: OrganizationInvitation,
},
},
{ mfaIssuer: 'Acme' },
)It delegates to lucidAccountStore, so the capability rules are identical: pass the
webauthnCredential model and passkeys light up, leave organizations out and
supportsOrganizations is false. patStore and audit are returned only when their
models are supplied.
The model mixins
Compose your AuthUser model from AuthKit's mixins so it has the columns and methods the
store expects:
withAuthUser— base identity columns (email, password hash, globalRoles). It does not declare a primary key or generate one — see below.withCredentials— email verification + password-reset token plumbing.withPersonalAccessToken— relation/helpers for PAT.withMfa— TOTP secret,mfaEnabledAt, and recovery codes for MFA.withAuditLog— typedAuditEventTypecolumns for an audit-log model.
None of the mixins declare an id column or a fullName column, and both are required:
idneeds a@beforeCreatehook assigning a real value (e.g.randomUUID()) andstatic selfAssignPrimaryKey = true. Without the hook, Lucid insertsNULLfor this string column. Without the flag, Lucid still overwrites the id the hook just assigned with whatever the raw INSERT returns (the database's internal auto-increment rowid) the instant the row is saved. Either way the account becomes unreachable by its real id on the very next request.fullNameis required because the built-in signup screen always collects a "Name" field, and the Lucid store passes it straight toAuthUser.create().
node ace configure @adonis-agora/authkit-server scaffolds both the model below and
a matching migration at database/migrations/<timestamp>_create_auth_users_table.ts —
run node ace migration:run once before the first signup. Its columns are exactly the
ones this page documents: id (string primary key, not auto-increment — the
@beforeCreate hook needs somewhere to put the UUID), email (unique), password,
global_roles (JSON, defaults to []), the four withCredentials columns
(email_verified_at, email_verification_token, password_reset_token,
password_reset_expires_at), and full_name. If you add your own @column()s to
AuthUser (e.g. disabled_at for AccountStatusCapability, avatar_url for
ProfileCapability), extend that migration yourself with an alterTable — node ace configure only scaffolds the columns the minimal AuthUser needs.
import { randomUUID } from 'node:crypto'
import { BaseModel, beforeCreate, column } from '@adonisjs/lucid/orm'
import { compose } from '@adonisjs/core/helpers'
import { withAuthUser, withCredentials, withMfa } from '@adonis-agora/authkit-server'
export default class AuthUser extends compose(
BaseModel,
withAuthUser(),
withCredentials(),
withMfa()
) {
static selfAssignPrimaryKey = true
@column({ isPrimary: true })
declare id: string
@beforeCreate()
static assignUuid(user: AuthUser) {
user.id = randomUUID()
}
@column()
declare fullName: string | null
}Bringing your own store
If your users don't live in a Lucid model — or you need custom logic — implement
CoreAccountStore yourself and pass it to accountStore. As long as the core methods
fulfil the contract above, every host-kit flow keeps working; you simply start with the
smallest AuthKit and add features by adding capabilities.
import type { AccountStore, AuthAccount } from '@adonis-agora/authkit-server'
export function directoryAccountStore(): AccountStore {
return {
async findById(id) { /* … */ },
async findByEmail(email) { /* … */ },
async verifyCredentials(email, password) { /* … */ },
async create(input) { /* … */ },
async issuePasswordResetToken(email) { /* … */ },
async consumePasswordResetToken(token, newPassword) { /* … */ },
async issueEmailVerificationToken(email) { /* … */ },
async consumeEmailVerificationToken(token) { /* … */ },
async listAccounts(params) { /* … */ },
async setGlobalRoles(accountId, roles) { /* … */ },
// One capability, implemented whole. Now `supportsProfile(store)` is true and
// the Profile card appears on /account/security.
async updateProfile(accountId, patch) { /* … */ },
}
}Never stub a capability method with a throwing placeholder to "satisfy the interface".
The supports* guards — and therefore the UI, the REST responses and the login flow —
read method presence. A present-but-throwing method advertises a feature you do not have,
and the failure surfaces to a user mid-flow instead of never being offered.