---
title: "Configuration — Two-Factor for Laravel | Roundly"
description: "Every config/two-factor.php key and default — TOTP parameters, issuer, recovery codes, limiter, replay guard, table and columns."
url: https://roundly-consulting.com/open-source/docs/two-factor-for-laravel/configuration
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)

# Configuration

The published config/two-factor.php in full. The TOTP defaults match standard authenticator apps (RFC 6238):

```php
use RoundlyConsulting\Crypto\Otp\OtpAlgorithm;
use RoundlyConsulting\TwoFactor\Enums\RecoveryCodeStorage;
use RoundlyConsulting\TwoFactor\Enums\ReplayGuardMode;

return [
    // TOTP parameters — defaults match standard authenticator apps (RFC 6238).
    // Bounds are enforced at runtime; out-of-range or non-integer values (e.g.
    // 'five') throw InvalidTwoFactorConfigException rather than silently weakening 2FA.
    'algorithm' => OtpAlgorithm::Sha1->value,     // 'sha1' | 'sha256' | 'sha512'
    'digits' => 6,                                // 6–8
    'period' => 30,                               // 15–120 seconds per timestep
    'window' => 1,                                // 0–2: accept ±N timesteps of drift
    'secret_length' => 32,                        // base32 chars (16–4096); 32 = 160 bits; 1/3/6 (mod 8) round up by one

    // Provisioning (otpauth:// URI). issuer falls back to config('app.name') at runtime.
    'issuer' => env('TWO_FACTOR_ISSUER'),         // null/blank → app.name

    'recovery_codes' => [
        'count' => 8,
        'storage' => RecoveryCodeStorage::Hashed->value, // 'hashed' (default) | 'encrypted'
    ],

    // Built-in brute-force limiter for attempt(), keyed per user. Set to null
    // to disable it and rely on your own throttle middleware instead.
    'attempts' => [
        'max' => 5,                               // failed attempts before lockout
        'decay' => 60,                            // seconds the lockout lasts
    ],

    // Replay protection: reject any code whose timestep <= the last successful one.
    'replay_guard' => ReplayGuardMode::Column->value, // 'column' | 'cache' | 'none' (null → none; blank → column)
    'cache' => [
        'store' => env('TWO_FACTOR_CACHE_STORE'), // null/blank → default store (used by cache guard)
        'ttl' => 60 * 60 * 24,                    // seconds to retain last timestep in cache mode (≥ (2 × window + 1) × period)
    ],

    // The table the published migration adds the columns to. Other account
    // tables get them via `$table->twoFactorColumns()` in your own migration.
    'table' => env('TWO_FACTOR_TABLE', 'users'),

    // Column names on the host account table(s) — 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',
    ],
];
```

## Every key

| Key | Default | Env | Purpose |
| --- | --- | --- | --- |
| `algorithm` | sha1 | — | HMAC hash — sha1, sha256 or sha512. Keep sha1 for authenticator-app compatibility. |
| `digits` | 6 | — | Code length, 6–8. |
| `period` | 30 | — | Seconds per timestep, 15–120. |
| `window` | 1 | — | Accepted clock drift in ± timesteps, 0–2. An explicit $window passed to verify() is bound the same way. |
| `secret_length` | 32 | — | Base32 secret length, 16–4096 characters; 32 characters = 160 bits. A length no base32 string can have (1, 3 or 6 mod 8, e.g. 17 or 30) is rounded up one character, so the secret always decodes. |
| `issuer` | null | TWO\_FACTOR\_ISSUER | Provisioning issuer; null or blank falls back to app.name. A non-string value throws. |
| `recovery_codes.count` | 8 | — | Recovery codes generated per enrolment (at least 1). |
| `recovery_codes.storage` | hashed | — | hashed (one-way) or encrypted (reversible, can be displayed again). |
| `attempts` | \['max' => 5, 'decay' => 60\] | — | Built-in per-user brute-force limiter; only null disables it — false, 'off' or 0 throws, and a blank value keeps the shipped limits. |
| `attempts.max` | 5 | — | Failed attempts before lockout (≥1). |
| `attempts.decay` | 60 | — | Seconds the lockout lasts (≥1). |
| `replay_guard` | column | — | Where the last-used timestep lives: column, cache, or none/null to disable. A blank value is not set and keeps column. Strings only — false or 0 throws. |
| `cache.store` | null | TWO\_FACTOR\_CACHE\_STORE | Cache store for the cache guard; null or blank = the default store. A non-string value throws. |
| `cache.ttl` | 86400 | — | Seconds to retain the last timestep in cache mode — at least (2 × window + 1) × period (90 with the defaults), the longest a claimed code stays valid. |
| `table` | users | TWO\_FACTOR\_TABLE | Table the published migration alters; users when not set (absent, null or blank). A non-string value throws. |
| `columns.secret` | two\_factor\_secret | — | Encrypted secret column. A blank columns.\* name is not set and takes its default; a non-string name throws. |
| `columns.recovery_codes` | two\_factor\_recovery\_codes | — | Recovery-codes column. |
| `columns.confirmed_at` | two\_factor\_confirmed\_at | — | Confirmation timestamp. |
| `columns.last_used_timestep` | two\_factor\_last\_used\_timestep | — | Last-used timestep, read by the column replay guard. |

## Bounds are enforced

digits (6–8), period (15–120), window (0–2), secret\_length (16–4096) and attempts.max / attempts.decay (at least 1) are validated at runtime. An out-of-range value — or one that isn’t an integer at all, or an unknown algorithm, recovery\_codes.storage or replay\_guard — throws InvalidTwoFactorConfigException instead of silently weakening 2FA.

cache.ttl must be at least (2 × window + 1) × period seconds — 90 with the defaults — and is checked when the cache replay guard resolves: a shorter entry would expire while the claimed code still verifies, and the same code would be accepted again. The shipped 86400 is unaffected.

## Strict reads

Every setting is read strictly. A key that is not set — absent, null or blank ('' or whitespace, what a KEY= line in .env gives) — takes the default above; a present value of the wrong shape throws InvalidTwoFactorConfigException naming the key. Integers accept an int or a canonical integer string ('30', as env values arrive), so 'five' or '1.5' throws rather than becoming 0 — a junk window never silently disables drift. attempts is switched off by null only; false, 'off' or 0 throws, and a blank value keeps the shipped limits. replay\_guard is switched off by null or none only; a blank value keeps the column guard. A non-string issuer throws (a blank one is not set, so app.name applies), and so does a non-string table or column name — a blank one takes its default.

## Backing enums

The string values are backed by enums, so an unknown value fails at resolution — never a silent hash downgrade:

```php
use RoundlyConsulting\Crypto\Otp\OtpAlgorithm;              // Sha1, Sha256, Sha512 — backs `algorithm`
use RoundlyConsulting\TwoFactor\Enums\RecoveryCodeStorage;  // Encrypted, Hashed; cast(); ::fromConfig()
use RoundlyConsulting\TwoFactor\Enums\ReplayGuardMode;      // Column, Cache, None; ::fromConfig() (null → None)
use RoundlyConsulting\TwoFactor\Enums\TwoFactorMethod;      // Totp ('totp'), RecoveryCode ('recovery_code')
```

## Environment

Three keys read environment variables:

```bash
TWO_FACTOR_ISSUER="Acme"
TWO_FACTOR_CACHE_STORE=redis
TWO_FACTOR_TABLE=users
```

## Inspecting the configuration

The package adds a section to php artisan about. It reports the algorithm, code length and period, drift window, secret length, whether an issuer is set, the recovery-code count and storage mode, the replay guard, the attempt limit and whether the column map is default or remapped — never a secret, a recovery code, the issuer itself or a cache store name:

```bash
php artisan about --only=two-factor
```

Values are read raw, so the section still renders on a misconfigured host. An enum case renders as its value; a value of the wrong type — or, on a number row, a string that isn’t an integer, such as '6abc' or '1.5' — shows as invalid (<type>) on its row, never as a default or a cast number. An out-of-range number renders as itself.

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