NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Two-Factor for Laravel

Migrating from another TOTP library

TOTP itself is portable. This package follows the standard profile (SHA1, 6 digits, 30 seconds) that authenticator apps use, so a base32 secret minted by any RFC 6238 library keeps generating the same codes: your users keep their authenticator entries and never re-enrol. Compatibility is proven by committed static parity fixtures; no third-party TOTP library is a dependency of this package.

What does not carry over by itself is how the old library stored the columns. This package reads the secret through Laravel’s encrypted cast (Crypt::encryptString()), and recovery codes as a JSON list — encrypted the same way in encrypted storage, one-way hashes in hashed. Every existing row has to be in that format, so plan a one-off data migration.

From Laravel Fortify

Fortify uses the same column names but stores encrypt($secret) and encrypt(json_encode($codes)) — serialized payloads. Read as they are, the secret comes back as a serialized string (every attempt() then throws InvalidBase32Exception) and the recovery codes as an empty list. Don’t publish this package’s migration — the columns already exist. Instead, swap Fortify’s TwoFactorAuthenticatable trait for HasTwoFactorAuthentication (see Model setup) and run this migration once, under the same APP_KEY:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        // Fortify only adds two_factor_confirmed_at when its `confirm` option is on.
        $confirms = Schema::hasColumn('users', 'two_factor_confirmed_at');

        Schema::table('users', function (Blueprint $table) use ($confirms): void {
            if (! $confirms) {
                $table->timestamp('two_factor_confirmed_at')->nullable();
            }

            $table->unsignedBigInteger('two_factor_last_used_timestep')->nullable();
        });

        DB::table('users')->whereNotNull('two_factor_secret')->lazyById()->each(
            function (object $user) use ($confirms): void {
                DB::table('users')->where('id', $user->id)->update([
                    // Crypt::decrypt() unserializes Fortify's payload; encryptString() is what the cast reads.
                    'two_factor_secret' => Crypt::encryptString(Crypt::decrypt($user->two_factor_secret)),
                    'two_factor_recovery_codes' => $user->two_factor_recovery_codes === null
                        ? null
                        : Crypt::encryptString(Crypt::decrypt($user->two_factor_recovery_codes)),
                    // Without Fortify's confirmation step, every stored secret was a live second factor.
                    'two_factor_confirmed_at' => $confirms ? $user->two_factor_confirmed_at : now(),
                ]);
            },
        );
    }
};

Then keep the imported codes matchable — they arrive in plaintext, so store them reversibly:

// config/two-factor.php
'recovery_codes' => [
    'count' => 8,
    'storage' => 'encrypted',
],

With confirmation on, a Fortify row that has a secret but no two_factor_confirmed_at was an unfinished enrolment, and it stays pending here too. If you switched Fortify’s confirm option off after migrating, stamp two_factor_confirmed_at for those rows yourself. To move to the default hashed storage later, switch the setting and have users regenerate their codes — switching modes invalidates stored codes.

From other libraries

Apply the same rule per column: the secret must be Crypt::encryptString($base32Secret) — wrap a plaintext value directly, or Crypt::decrypt() an encrypt()-serialized one first. Plaintext recovery codes become Crypt::encryptString(json_encode($codes)) under encrypted storage:

use Illuminate\Support\Facades\Crypt;

// The secret column — what the encrypted cast reads:
Crypt::encryptString($base32Secret);                    // from a plaintext secret
Crypt::encryptString(Crypt::decrypt($storedSecret));    // from an encrypt()-serialized one

// Plaintext recovery codes, under 'storage' => 'encrypted':
Crypt::encryptString(json_encode($codes));

Add two_factor_last_used_timestep — and two_factor_confirmed_at, set for every enrolled user — if the old schema lacks them. The replay column has the same type the macro creates:

Schema::table('users', function (Blueprint $table): void {
    $table->unsignedBigInteger('two_factor_last_used_timestep')->nullable();
});

Different column names

If your existing columns are named differently, remap them instead of renaming — every account table shares the one map:

// config/two-factor.php — remap for non-standard schemas
'columns' => [
    'secret' => 'two_factor_secret',
    'recovery_codes' => 'two_factor_recovery_codes',
    'confirmed_at' => 'two_factor_confirmed_at',
    'last_used_timestep' => 'two_factor_last_used_timestep',
],

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.