diff --git a/bin/access-check-manager.pl b/bin/access-check-manager.pl
index 4c8a22769376bcdf0ba34451cf43e9a8e56967af..e6de646c7d9712ea2c7f800e71c5061caa3834dc 100755
--- a/bin/access-check-manager.pl
+++ b/bin/access-check-manager.pl
@@ -107,7 +107,7 @@ sub add_account {
         scope           => $configuration->{idp}->{scope},
         password        => $password,
         password_crypt  => AccessCheck::Tools::encrypt($password, $key),
-        password_hash   => AccessCheck::Tools::sha256_hash($password),
+        password_hash   => AccessCheck::Tools::hash($password),
         token           => $secret,
         creation_date   => DateTime->now(),
         expiration_date => DateTime->now()->add(days => $validity_period)
diff --git a/lib/AccessCheck/App/Step4.pm b/lib/AccessCheck/App/Step4.pm
index ce72f758a6e8295d1a10adcfeca1d7ec6d806c98..f3b68a1bd06f3f26bbde8f2a53c4a3f743a74d9e 100644
--- a/lib/AccessCheck/App/Step4.pm
+++ b/lib/AccessCheck/App/Step4.pm
@@ -79,7 +79,7 @@ sub run {
             scope           => $config->{idp}->{scope},
             password        => $password,
             password_crypt  => AccessCheck::Tools::encrypt($password, $key),
-            password_hash   => AccessCheck::Tools::sha256_hash($password),
+            password_hash   => AccessCheck::Tools::hash($password),
             token           => $download_token->secret(),
             creation_date   => $creation_date,
             expiration_date => $account_expiration_date,
diff --git a/lib/AccessCheck/Tools.pm b/lib/AccessCheck/Tools.pm
index 8ae59a876b34de1ebe0590ae64636cc50a85d237..dc8b3207b40fcc0dac39d292baee95d5d4e70a7e 100644
--- a/lib/AccessCheck/Tools.pm
+++ b/lib/AccessCheck/Tools.pm
@@ -2,7 +2,8 @@ package AccessCheck::Tools;
 
 use Mojo::Base -strict;
 
-use Digest::SHA;
+use Crypt::Bcrypt;
+use Crypt::OpenSSL::Random ();
 use Encode;
 use English qw(-no_match_vars);
 use List::Util qw(shuffle);
@@ -13,6 +14,12 @@ use Template::Constants qw(:chomp);
 
 use AccessCheck::Template::Plugin::Quote;
 
+use constant {
+    SIG_BCRYPT                       => '2y',
+    PASSWORD_BCRYPT_DEFAULT_COST     => 10,
+    PASSWORD_BCRYPT_MAX_PASSWORD_LEN => 72,
+};
+
 sub encrypt {
     my ($string, $key) = @_;
 
@@ -42,11 +49,26 @@ sub otp {
     return join('', @chars);
 }
 
-# get SHA256 hash for a string
-sub sha256_hash {
-    my ($s) = @_;
+# shamelessly stolen from PHP::Functions::Password
+# https://metacpan.org/dist/PHP-Functions-Password/source/lib/PHP/Functions/Password.pm
+sub hash {
+    my ($string) = @_;
+
+    my $salt = Crypt::OpenSSL::Random::random_bytes(16);
+    my $cost = PASSWORD_BCRYPT_DEFAULT_COST;
+
+    # Treat passwords as strings of bytes
+    # "\x{100}"  becomes "\xc4\x80"; preferred equivalent of Encode::is_utf8($string) && Encode::_utf8_off($password);
+    utf8::is_utf8($string) && utf8::encode($string);
+
+    # Everything beyond the max password length in bytes for bcrypt is silently ignored.
+    require bytes;
+    if (bytes::length($string) > PASSWORD_BCRYPT_MAX_PASSWORD_LEN) {
+        # $string is already bytes, so the bytes:: prefix is redundant here
+        $string = substr($string, 0, PASSWORD_BCRYPT_MAX_PASSWORD_LEN);
+    }
 
-    return Digest::SHA::sha256_base64($s);
+    return Crypt::Bcrypt::bcrypt($string, SIG_BCRYPT, $cost, $salt);
 }
 
 sub generate_password {