Skip to content

marko/authentication-database

Database-backed password reset token storage --- reset tokens survive cache flushes and auth:clear-reset-tokens deletes expired rows. Installing the package replaces CachePasswordResetTokenRepository, the cache default of marko/authentication, with DatabasePasswordResetTokenRepository, which keeps one hashed token per user in a password_reset_tokens table.

Implements PasswordResetTokenRepositoryInterface from marko/authentication and requires marko/database for the database connection.

Terminal window
composer require marko/authentication-database

Requires marko/database and one of its drivers, such as marko/database-mysql or marko/database-pgsql.

The package has no config file of its own. It reads two keys of config/authentication.php:

KeyUsed for
password_reset.providerThe provider column of every row the repository writes, reads or deletes
password_reset.expire_secondsHow old a token may be before auth:clear-reset-tokens deletes it

created_at is written in the database timezone, database.timezone in config/database.php (UTC by default), so a stored instant never depends on the PHP timezone of the process that wrote it.

Installing the package is all that is required. PasswordResetBrokerInterface keeps working unchanged: it now stores and redeems tokens through the table instead of the cache. An app module that binds PasswordResetTokenRepositoryInterface itself still overrides both repositories.

The package ships a PasswordResetToken entity (Marko\Authentication\Database\Entity\PasswordResetToken) that owns the password_reset_tokens table schema. Create the table with marko db:migrate:

Terminal window
marko db:migrate

In development, db:migrate generates a migration for the table in database/migrations/ and applies it. Commit that migration; db:migrate on staging and production applies it. The table has these columns on MySQL, MariaDB and PostgreSQL:

ColumnTypeNotes
idIntegerAuto-increment primary key
providerVARCHAR(100)The password_reset.provider the token was issued for
user_idVARCHAR(255)The user’s auth identifier as a string, so integer, UUID and email identifiers all fit
token_hashVARCHAR(64)SHA-256 hash of the token; the token itself is never stored
created_atDATETIMEWhen the token was issued, in the database timezone

A unique index on (provider, user_id) keeps one token per user: issuing a new link replaces the old token through a single upsert (ON DUPLICATE KEY UPDATE on MySQL, ON CONFLICT DO UPDATE on PostgreSQL), so two reset requests racing for one user still leave one row. An index on created_at serves the cleanup.

The table stores no expiry. The broker rejects a token once it is expire_seconds old, measured from created_at, so changing expire_seconds applies to tokens already issued.

The entity only owns the schema: DatabasePasswordResetTokenRepository reads and writes the table with its own SQL. Because the table belongs to an entity, db:diff reports drift in it and TruncateDatabase empties it between tests.

A used token is deleted when it is redeemed, but a token nobody redeems stays in the table after it expires. Run the cleanup command on a schedule:

Terminal window
marko auth:clear-reset-tokens

It deletes every token created more than expire_seconds ago, of every provider, and reports how many it removed. Tokens left behind by a provider the app no longer resets passwords for are cleared too.

Rows are keyed by provider and user id, so a token issued for user 7 of the users provider never matches user 7 of admins. If you change password_reset.provider, tokens issued under the old provider stop matching anyone and auth:clear-reset-tokens removes them once they expire.

Implements PasswordResetTokenRepositoryInterface. Takes a ConnectionInterface, a QueryBuilderFactoryInterface (for the driver’s upsert), the AuthConfig and the DatabaseTimezoneConfig; all four are autowired.

MethodDescription
store(int|string $userId, string $tokenHash, DateTimeImmutable $createdAt): voidUpsert the user’s row for the configured provider, replacing any token the user already has.
find(int|string $userId): ?PasswordResetTokenRecordThe user’s token hash and creation time for the configured provider, or null.
delete(int|string $userId): voidDelete the user’s token for the configured provider.
deleteExpired(DateTimeImmutable $before): intDelete every token, of every provider, created before $before. Returns the number of deleted rows.

The entity that owns the password_reset_tokens schema. It is not used to read or write rows.