Passwords Done Properly
Hashing with the auto algorithm, upgrading old hashes during login, keeping plaintext out of logs, and timing-safe comparison.
UserPasswordHasherInterface::hashPassword(PasswordAuthenticatedUserInterface $user, string $plainPassword): stringA password is stored as a hash: a one-way value you can verify against but cannot reverse. Symfony's password hasher is the service that produces and checks those values, so you never call password_hash() yourself and never choose a salt.
The hasher
Inject UserPasswordHasherInterface. It has three methods. hashPassword($user, $plainPassword) returns the hash to store. isPasswordValid($user, $plainPassword) checks a submitted password against the stored one. needsRehash($user) reports whether that user's stored hash was produced with settings that are now out of date. The user object is a parameter because the algorithm is configured per user class.
If the user signs in with a password, also implement PasswordAuthenticatedUserInterface. Its single method, getPassword(): ?string, returns the stored hash. A user who only ever signs in through an external provider does not need it.
The auto algorithm
Set algorithm: auto and Symfony selects the strongest hasher available, which is bcrypt today and may be something else in a later release. Because the choice can change, give the column at least 255 characters: bcrypt hashes are 60 characters, sodium hashes are 96.
Upgrading old hashes
List the algorithms your existing rows use under migrate_from. A successful login then verifies with the old algorithm, rehashes the submitted password with the new one, and asks the provider to save it. That last step only happens if your repository implements PasswordUpgraderInterface.
Handling the plaintext
Mark the parameter #[\SensitiveParameter] so PHP redacts it in stack traces, and keep it out of log lines and exception messages. When you compare raw secrets of your own, use hash_equals() rather than ===.
Example
<?php
// src/Security/Registration.php
declare(strict_types=1);
namespace App\Security;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
final class Registration
{
public function __construct(
private readonly UserPasswordHasherInterface $hasher,
private readonly EntityManagerInterface $entityManager,
) {
}
// #[\SensitiveParameter] makes PHP print "Object(SensitiveParameterValue)"
// in place of the password in any stack trace through this method.
public function register(string $email, #[\SensitiveParameter] string $plainPassword): User
{
$user = new User();
$user->setEmail($email);
// The returned string carries the algorithm, its cost and a random salt.
$user->setPassword($this->hasher->hashPassword($user, $plainPassword));
$this->entityManager->persist($user); // schedules the insert
$this->entityManager->flush(); // writes it to the database
return $user;
}
}
// Output: $user->getPassword() holds a 60-character bcrypt hash beginning
// '$2y$13$'. It differs on every call because the salt is random, so two
// hashes of the same password never match with ===. Compare with
// isPasswordValid() instead.When to use it
- Hash a password during registration without picking an algorithm or a salt yourself.
- Move an imported sha256 users table onto bcrypt gradually, as people log in.
- Cut the test suite runtime by lowering the bcrypt cost in the test environment only.
- Verify an incoming webhook signature without leaking the secret through response timing.
More examples
auto, plus a legacy algorithm to migrate away from
The first file is the real configuration: new passwords use auto, and a login against an old sha256 hash still verifies. The second file matters more than it looks. Every bcrypt cost increment doubles the hashing time, so a suite that creates users spends most of its run hashing. Dropping cost to 4 in the test environment only is safe because nothing there is a real credential, and it is the cheapest available speed-up for a test suite with authentication in it.
# config/packages/security.yaml
security:
password_hashers:
# A named hasher describing how the imported rows were hashed.
legacy_sha256:
algorithm: sha256
encode_as_base64: false
iterations: 1
App\Entity\User:
algorithm: auto
migrate_from:
- legacy_sha256
# config/packages/test/security.yaml
security:
password_hashers:
App\Entity\User:
algorithm: auto
cost: 4 # bcrypt minimum; the default of 13 is deliberately slow
time_cost: 3 # sodium and argon minimums, in case auto picks one
memory_cost: 10Saving the upgraded hash
Symfony calls this after a successful login when the stored hash came from an algorithm listed in migrate_from, or when needsRehash() reports that the cost has moved on. It receives the already-hashed value, so there is nothing to hash here and nothing to validate. No persist() call is needed because the user came out of this repository and is already managed by the entity manager; flush() alone writes the update. Leave this method out and migrate_from quietly does half its job forever.
<?php
// src/Repository/UserRepository.php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
/**
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function upgradePassword(
PasswordAuthenticatedUserInterface $user,
string $newHashedPassword,
): void {
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Expected %s, got %s.', User::class, $user::class));
}
$user->setPassword($newHashedPassword);
$this->getEntityManager()->flush(); // already managed, so no persist() needed
}
}Comparing a secret without leaking timing
The same discipline as password checking, applied to a secret you compare yourself. The password hasher already does constant-time verification internally, so you never need this for passwords, but you do need it for webhook signatures, unsubscribe tokens, invitation codes and anything else where an attacker can submit guesses and measure the response. hash_equals takes the expected value first and the untrusted value second, and returns false rather than throwing when the lengths differ. Keep the secret in an environment variable or the secrets vault, not in the class.
<?php
// src/Security/WebhookSignature.php
declare(strict_types=1);
namespace App\Security;
final class WebhookSignature
{
public function __construct(
#[\SensitiveParameter] private readonly string $sharedSecret,
) {
}
public function isValid(string $payload, string $providedSignature): bool
{
$expected = hash_hmac('sha256', $payload, $this->sharedSecret);
// === stops at the first differing byte, so how long it takes reveals
// how much of the signature was right. hash_equals compares every byte
// regardless, in time that does not depend on the contents.
return hash_equals($expected, $providedSignature);
}
}
// Output: true only when $providedSignature is the HMAC-SHA256 of $payload
// under the shared secret; false for anything else, including an empty string.
Discussion