diff --git a/src/HOTP.php b/src/HOTP.php
index 5336a605c8f038e18e86c8954069125202acd048..b717e13ac7551f9723c5feb9242e4b85ea594152 100644
--- a/src/HOTP.php
+++ b/src/HOTP.php
@@ -6,6 +6,7 @@ namespace OTPHP;
 
 use InvalidArgumentException;
 use function is_int;
+use ParagonIE\ConstantTime\Base32;
 
 /**
  * @see \OTPHP\Test\HOTPTest
@@ -27,6 +28,22 @@ final class HOTP extends OTP implements HOTPInterface
         return new self($secret, $counter, $digest, $digits);
     }
 
+    public static function createFromSecret(
+        string $secret,
+        int $counter = 0,
+        string $digest = 'sha1',
+        int $digits = 6
+    ): self {
+        return new self($secret, $counter, $digest, $digits);
+    }
+
+    public static function generate(int $counter = 0, string $digest = 'sha1', int $digits = 6): self
+    {
+        $secret = Base32::encodeUpper(random_bytes(64));
+
+        return new self($secret, $counter, $digest, $digits);
+    }
+
     public function getCounter(): int
     {
         $value = $this->getParameter('counter');
diff --git a/src/HOTPInterface.php b/src/HOTPInterface.php
index 1f9cdd7f8fd863db555beaccad5efbcce46d4b0e..011f86abe9a2ada58bfc39b2f22344ce4c455f3c 100644
--- a/src/HOTPInterface.php
+++ b/src/HOTPInterface.php
@@ -12,7 +12,7 @@ interface HOTPInterface extends OTPInterface
     public function getCounter(): int;
 
     /**
-     * Create a new TOTP object.
+     * Create a new HOTP object.
      *
      * If the secret is null, a random 64 bytes secret will be generated.
      */
@@ -22,4 +22,21 @@ interface HOTPInterface extends OTPInterface
         string $digest = 'sha1',
         int $digits = 6
     ): self;
+
+    /**
+     * Create a TOTP object from an existing secret.
+     *
+     * @param non-empty-string $secret
+     */
+    public static function createFromSecret(
+        string $secret,
+        int $counter = 0,
+        string $digest = 'sha1',
+        int $digits = 6
+    ): self;
+
+    /**
+     * Create a new HOTP object. A random 64 bytes secret will be generated.
+     */
+    public static function generate(int $counter = 0, string $digest = 'sha1', int $digits = 6): self;
 }
diff --git a/src/ParameterTrait.php b/src/ParameterTrait.php
index cef50f3e53a503fee583637292413b68870b98bf..2a01ca657d6970d74e754c54911f823e5abcab4f 100644
--- a/src/ParameterTrait.php
+++ b/src/ParameterTrait.php
@@ -9,7 +9,6 @@ use function in_array;
 use InvalidArgumentException;
 use function is_int;
 use function is_string;
-use ParagonIE\ConstantTime\Base32;
 
 trait ParameterTrait
 {
@@ -136,10 +135,6 @@ trait ParameterTrait
                 return $value;
             },
             'secret' => static function ($value): string {
-                if ($value === null) {
-                    $value = Base32::encodeUpper(random_bytes(64));
-                }
-
                 return mb_strtoupper(trim($value, '='));
             },
             'algorithm' => static function ($value): string {
diff --git a/src/TOTP.php b/src/TOTP.php
index 2e673675e9c15f29f2a4b52ffa480301f258b96a..3835881c70937bd640599bc2f07c155d80d5ff13 100644
--- a/src/TOTP.php
+++ b/src/TOTP.php
@@ -6,6 +6,7 @@ namespace OTPHP;
 
 use InvalidArgumentException;
 use function is_int;
+use ParagonIE\ConstantTime\Base32;
 
 /**
  * @see \OTPHP\Test\TOTPTest
@@ -29,6 +30,27 @@ final class TOTP extends OTP implements TOTPInterface
         return new self($secret, $period, $digest, $digits, $epoch);
     }
 
+    public static function createFromSecret(
+        string $secret,
+        int $period = 30,
+        string $digest = 'sha1',
+        int $digits = 6,
+        int $epoch = 0
+    ): self {
+        return new self($secret, $period, $digest, $digits, $epoch);
+    }
+
+    public static function generate(
+        int $period = 30,
+        string $digest = 'sha1',
+        int $digits = 6,
+        int $epoch = 0
+    ): self {
+        $secret = Base32::encodeUpper(random_bytes(64));
+
+        return new self($secret, $period, $digest, $digits, $epoch);
+    }
+
     public function getPeriod(): int
     {
         $value = $this->getParameter('period');
diff --git a/src/TOTPInterface.php b/src/TOTPInterface.php
index 3a6f3e7be593d39dc719fd5d0f588caf673bcb91..550e9ff08d7088ddeca9ca760fcc0866ad758181 100644
--- a/src/TOTPInterface.php
+++ b/src/TOTPInterface.php
@@ -18,6 +18,23 @@ interface TOTPInterface extends OTPInterface
         int $digits = 6
     ): self;
 
+    /**
+     * Create a TOTP object from an existing secret.
+     *
+     * @param non-empty-string $secret
+     */
+    public static function createFromSecret(
+        string $secret,
+        int $period = 30,
+        string $digest = 'sha1',
+        int $digits = 6
+    ): self;
+
+    /**
+     * Create a new TOTP object. A random 64 bytes secret will be generated.
+     */
+    public static function generate(int $period = 30, string $digest = 'sha1', int $digits = 6): self;
+
     /**
      * Return the TOTP at the current time.
      */
diff --git a/tests/HOTPTest.php b/tests/HOTPTest.php
index b0acca0bf723d1bd1164d06da4768208d194c0a9..e21c2d609c85ae4ecbd78cba2c3431e7e2747b8f 100644
--- a/tests/HOTPTest.php
+++ b/tests/HOTPTest.php
@@ -21,7 +21,7 @@ final class HOTPTest extends TestCase
     {
         $this->expectException(InvalidArgumentException::class);
         $this->expectExceptionMessage('The label is not set.');
-        $hotp = HOTP::create();
+        $hotp = HOTP::generate();
         $hotp->getProvisioningUri();
     }
 
@@ -32,7 +32,7 @@ final class HOTPTest extends TestCase
     {
         $this->expectException(InvalidArgumentException::class);
         $this->expectExceptionMessage('Issuer must not contain a colon.');
-        $otp = HOTP::create('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8);
+        $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8);
         $otp->setLabel('alice');
         $otp->setIssuer('foo%3Abar');
     }
@@ -44,7 +44,7 @@ final class HOTPTest extends TestCase
     {
         $this->expectException(InvalidArgumentException::class);
         $this->expectExceptionMessage('Issuer must not contain a colon.');
-        $otp = HOTP::create('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8);
+        $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8);
         $otp->setLabel('alice');
         $otp->setIssuer('foo%3abar');
     }
@@ -56,7 +56,7 @@ final class HOTPTest extends TestCase
     {
         $this->expectException(InvalidArgumentException::class);
         $this->expectExceptionMessage('Label must not contain a colon.');
-        $otp = HOTP::create('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8);
+        $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8);
         $otp->setLabel('foo%3Abar');
         $otp->getProvisioningUri();
     }
@@ -68,7 +68,7 @@ final class HOTPTest extends TestCase
     {
         $this->expectException(InvalidArgumentException::class);
         $this->expectExceptionMessage('Label must not contain a colon.');
-        $otp = HOTP::create('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8);
+        $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8);
         $otp->setLabel('foo:bar');
         $otp->getProvisioningUri();
     }
@@ -80,7 +80,7 @@ final class HOTPTest extends TestCase
     {
         $this->expectException(InvalidArgumentException::class);
         $this->expectExceptionMessage('Digits must be at least 1.');
-        HOTP::create('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 0);
+        HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 0);
     }
 
     /**
@@ -90,7 +90,7 @@ final class HOTPTest extends TestCase
     {
         $this->expectException(InvalidArgumentException::class);
         $this->expectExceptionMessage('Counter must be at least 0.');
-        HOTP::create('JDDK4U6G3BJLEZ7Y', -500);
+        HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', -500);
     }
 
     /**
@@ -100,7 +100,7 @@ final class HOTPTest extends TestCase
     {
         $this->expectException(InvalidArgumentException::class);
         $this->expectExceptionMessage('The "foo" digest is not supported.');
-        HOTP::create('JDDK4U6G3BJLEZ7Y', 0, 'foo');
+        HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 0, 'foo');
     }
 
     /**
@@ -114,7 +114,7 @@ final class HOTPTest extends TestCase
         $this->expectExceptionMessage('Unable to decode the secret. Is it correctly base32 encoded?');
         $secret = random_bytes(32);
 
-        $otp = HOTP::create($secret);
+        $otp = HOTP::createFromSecret($secret);
         $otp->at(0);
     }
 
@@ -123,7 +123,7 @@ final class HOTPTest extends TestCase
      */
     public function objectCreationValid(): void
     {
-        $otp = HOTP::create();
+        $otp = HOTP::generate();
 
         static::assertMatchesRegularExpression('/^[A-Z2-7]+$/', $otp->getSecret());
     }
@@ -184,7 +184,9 @@ final class HOTPTest extends TestCase
         string $label = 'alice@foo.bar',
         string $issuer = 'My Project'
     ): HOTP {
-        $otp = HOTP::create($secret, $counter, $digest, $digits);
+        static::assertNotSame('', $secret);
+
+        $otp = HOTP::createFromSecret($secret, $counter, $digest, $digits);
         $otp->setLabel($label);
         $otp->setIssuer($issuer);
 
diff --git a/tests/TOTPTest.php b/tests/TOTPTest.php
index 922deb01557543965dd56ab0f7ea9600530588f4..f28b7cb3b5aae839f057227babd6b8d817881335 100644
--- a/tests/TOTPTest.php
+++ b/tests/TOTPTest.php
@@ -24,7 +24,7 @@ final class TOTPTest extends TestCase
     {
         $this->expectException(InvalidArgumentException::class);
         $this->expectExceptionMessage('The label is not set.');
-        $otp = TOTP::create();
+        $otp = TOTP::generate();
         $otp->getProvisioningUri();
     }
 
@@ -33,7 +33,7 @@ final class TOTPTest extends TestCase
      */
     public function customParameter(): void
     {
-        $otp = TOTP::create('JDDK4U6G3BJLEZ7Y', 20, 'sha512', 8, 100);
+        $otp = TOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 20, 'sha512', 8, 100);
         $otp->setLabel('alice@foo.bar');
         $otp->setIssuer('My Project');
         $otp->setParameter('foo', 'bar.baz');
@@ -49,7 +49,7 @@ final class TOTPTest extends TestCase
      */
     public function objectCreationValid(): void
     {
-        $otp = TOTP::create();
+        $otp = TOTP::generate();
 
         static::assertMatchesRegularExpression('/^[A-Z2-7]+$/', $otp->getSecret());
     }
@@ -61,7 +61,7 @@ final class TOTPTest extends TestCase
     {
         $this->expectException(InvalidArgumentException::class);
         $this->expectExceptionMessage('Period must be at least 1.');
-        TOTP::create('JDDK4U6G3BJLEZ7Y', -20, 'sha512', 8);
+        TOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', -20, 'sha512', 8);
     }
 
     /**
@@ -71,7 +71,7 @@ final class TOTPTest extends TestCase
     {
         $this->expectException(InvalidArgumentException::class);
         $this->expectExceptionMessage('Epoch must be greater than or equal to 0.');
-        TOTP::create('JDDK4U6G3BJLEZ7Y', 30, 'sha512', 8, -1);
+        TOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 30, 'sha512', 8, -1);
     }
 
     /**
@@ -83,7 +83,7 @@ final class TOTPTest extends TestCase
         $this->expectExceptionMessage('Unable to decode the secret. Is it correctly base32 encoded?');
         $secret = random_bytes(32);
 
-        $otp = TOTP::create($secret);
+        $otp = TOTP::createFromSecret($secret);
         $otp->now();
     }
 
@@ -402,7 +402,9 @@ final class TOTPTest extends TestCase
         string $issuer = 'My Project',
         int $epoch = 0
     ): TOTP {
-        $otp = TOTP::create($secret, $period, $digest, $digits, $epoch);
+        static::assertNotSame('', $secret);
+
+        $otp = TOTP::createFromSecret($secret, $period, $digest, $digits, $epoch);
         $otp->setLabel($label);
         $otp->setIssuer($issuer);