---
title: "Migrating from another TOTP library — Two-Factor for Laravel | Roundly"
description: "Secrets from any RFC 6238 library keep their codes, so nobody re-enrols — re-encrypt the stored columns once. Fortify migration included."
url: https://roundly-consulting.com/open-source/docs/two-factor-for-laravel/migrating
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Two-Factor for Laravel](https://roundly-consulting.com/open-source/docs/two-factor-for-laravel.md)

# 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
<?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:

```php
// 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:

```php
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:

```php
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:

```php
// 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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/two-factor-for-laravel.md)
