- login/logout/register + session expiry
- email verification (
"Confirm your email"
) - password reset (
"Forgot password"
) - password confirmation (
"Re-enter your password"
) - persistent login (
"Remember me"
) - account lockout (
"Too many failed login attempts"
) - rate limiting (
"Too many requests"
)
- verifying user identity
- public (username/email) + private (pwd/token/2FA) info
- HTTP is a stateless protocol; each req is self-contained
- sessions used to retain user state between requests
- session cookie ties a request to the user's session
- idle/inactivity: sliding expiration, resets on each request
- absolute: fixed expiration, max duration of lifetime
- renewal: interval until session ID is regenerated
- hashing: fast & deterministic one-way transformation from plaintext to a digest
- keyless: message only, e.g.
MD5
,SHA1
,SHA256
,SHA512
- keyed: message + secret key, e.g.
HMAC SHA256
- keyless: message only, e.g.
- encryption: two-way transformation from plaintext to ciphertext
- symmetric: one key, e.g.
AES
- asymmetric: public + private key pair, e.g.
RSA
- symmetric: one key, e.g.
- encoding: reversible transformation without any keys
- e.g.
base64
,base64url
,hex
- e.g.
- compression: encoding that reduces original size
- e.g.
gzip
,brotli
- e.g.
- base64
[A-Za-z0-9+/=]
- encodes 3 bytes into 4 chars,
ceil(n / 3) * 4
- e.g.
20 bytes -> ceil(20 / 3) * 4 = 7 * 4 = 28 chars
- not URL-safe: (use
base64url
)
- encodes 3 bytes into 4 chars,
- hex/base16
[a-f0-9]
- encodes 1 byte into 2 chars,
n * 2
- e.g.
20 bytes -> 20 * 2 = 40 chars
- encodes 1 byte into 2 chars,
- server runs a plaintext pwd through a KDF (key derivation function)
- KDF appends a unique salt (often auto-generated) & produces a hash
- hash + salt (in plaintext) is stored in the password field in DB
- salt is not secret, only used against pre-computed rainbow tables
- when user re-enters the pwd, server runs it through the same hash func
- KDF extracts the salt, re-generates the hash, performs comparison
- given unique salts, two identical passwords produce different hashes
- attacker would need a rainbow table for each salt
- once hashed, original pwd is discarded; hash cannot be reversed
- fast hashes (
SHA1
,SHA256
, etc.) are not suitable- passwords lack entropy (short, weak), vulnerable to brute-force
- HMAC/SHA hashes are extremely fast to compute with cheap GPU
- KDFs run a pwd through many rounds of hashing
- adaptive work factor safeguards against advances in computing
Slow hash function, takes many iterations, designed against brute forcing
- CPU-intensive
- resilient to GPU acceleration
- RAM-intensive
- limit parallelism
- argon2
- bcrypt
- scrypt
- pbkdf2
Truncates input string
- on a null byte
0x00
(in C/C++) - after 72 bytes (in
utf8
)
- rainbow table attack: using a lookup table to derive original pwd from its hash
- dictionary attack: trying common passwords from a dict
- brute force attack: trying all possible password combinations
- salt: unique string appended to pwd before hashing
- not secret, stored in plaintext in DB, thwarts rainbow table attacks
- pepper: secret salt, i.e. key (either appended or signed with, e.g. HMAC)
- NOT stored in DB, only on the server; slows down brute-force attacks
- hash
bcrypt(passphrase, salt)
- pre-hash
bcrypt(sha256(passphrase).base64(), salt)
- sha256/sha512 digest may contain null bytes (end of string)
- do NOT use raw binary; wrap with base64
- pre-hash with a key (pepper)
bcrypt(hmac_sha256(passphrase, key).base64(), salt)
- hash & encrypt
aes256(bcrypt(passphrase, salt), key, iv)
- pre-hash & encrypt
aes256( bcrypt(sha256(passphrase).base64(), salt), key, iv )
- user signs up on the website
- server generates & signs an activation link
- link expires after X hours/days
- servers sends an email with the link
- user visits the link & verifies their account
- (optional) user requests the link to be resent
- if link hasn't expired, resend
- else, generate & email a new link
- token is not (easily) predictable
- URL is signed to guard against forgery
- older email links are valid until expiry
- Laravel
temporary (absolute) signed URL (HMAC SHA256, not persisted)
$parameters = ['id' => $user->id, 'hash' => sha1($user->email), 'expires' => Carbon::now()->addMinutes(60)];
return $this->route('verification.verify', $parameters + [
'signature' => hash_hmac('sha256', $this->routeUrl()->to($route, $parameters, $absolute), $key),
], $absolute);
// e.g. http://example.com/email/verify/{user.id}/{sha1(user.email)}?expires=1521543365&signature=d32f53ced4a781f287b612d21a3b7d3c38ebc5ae53951115bb9af4bc3f88a87a
See Illuminate/Routing/UrlGenerator.php
, Illuminate/Auth/Notifications/VerifyEmail.php
, and Illuminate/Foundation/Auth/VerifiesEmails.php
-
Rails/Devise
- random(20) token stored in plaintext
- doesn't log user in after verification
- re-sends current token, thus same email link
- if expired (after 3 days), regenerates & saves
- random(20) token stored in plaintext
if self.confirmation_token && !confirmation_period_expired?
@raw_confirmation_token = self.confirmation_token
else
self.confirmation_token = @raw_confirmation_token = SecureRandom.urlsafe_base64(20)
self.confirmation_sent_at = Time.now.utc
end
See devise/models/confirmable.rb
- Django/django-registration
username -> HMAC SHA1 (not persisted, expires after X day(s))
base64data = base64.urlsafe_b64encode( json.dumps(user.get_username()).encode('latin-1') )
key = hashlib.sha1('registration' + settings.SECRET_KEY).digest()
signature = hmac.new(key, msg=base64data, digestmod=hashlib.sha1)
token = f'{base64data}:{signature}'
See activation/views.py
and core/signing.py
- user visits "Forgot Password" page
- user fills in their email and submits the form
- server generates a unique & random token
- server hashes the token and saves the hash & exp. date in DB
- if DB is compromised, attacker can't use the hash to reset user pwd
- server constructs a link with plaintext token and emails the user
- user visits the link and submits a new password
- server verifies the token and updates user password
- at least 32 chars long (OWASP)
- generated using a secure PRNG (Pseudorandom number generator)
- don't use current time, user email/ID
- hashed before stored in DB (tokens = credentials)
- one-time use
- short-lived (has exp. date)
- when a new one is issued, delete all older tokens
- simpler DB design, poorer UX
- when the password is reset (either all or expired only)
- more complex DB design, better UX
- Laravel
random(40) -> HMAC SHA256 -> bcrypt
$token = hash_hmac('sha256', random_bytes(40), app['config']['app.key'])
$dbToken = password_hash($token, PASSWORD_BCRYPT, ['cost' => 10])
See Illuminate/Auth/Passwords/DatabaseTokenRepository.php
and Illuminate/Hashing/BcryptHasher.php
- Rails/Devise
random(20) -> HMAC SHA256
while find_first({ :reset_password_token => enc })
raw = SecureRandom.urlsafe_base64(20)
enc = OpenSSL::HMAC.hexdigest('SHA256', key, raw)
See devise/models/recoverable.rb
and devise/token_generator.rb
- Django
user state -> HMAC SHA1 (not persisted)
key = hashlib.sha1( # salt + secret
"django.contrib.auth.tokens.PasswordResetTokenGenerator" + settings.SECRET_KEY
).digest()
value = str(user.pk) + user.password + str(last_login) + str(timestamp)
# output: 160 bits / 8 bits/byte / 2 = 40/2 = 20
hash = hmac.new(key, msg=value, digestmod=hashlib.sha1).hexdigest()[::2]
return "%s-%s" % (days_since_2001_base36, hash)
See django/contrib/auth/tokens.py
and django/utils/crypto.py
- periodically require logged-in user to re-enter their password
- used on sensitive pages, e.g. payment info or orders
- Laravel
$confirmedAt = time() - $request->session()->get('auth.password_confirmed_at', 0);
return $confirmedAt > config('auth.password_timeout', 10800);
See laravel/framework#30214 and laravel/laravel#5129 PRs
- remember users on multiple devices
- sign the cookie to prevent forgery (and possibly encrypt)
- check for cookie expiry server-side
- once logged out, clear remember me token & unset the cookie
- extend expiration date (e.g. 2 hours -> 1 week)
- set a remember-me cookie with a signed token
- re-instate an auth session
- if detected an anomaly (e.g. pwd reset), unset cookie & return 401
- Laravel
remember_me
fieldVARCHAR(100)
inusers
table
public function login(AuthenticatableContract $user, $remember = false) {
// ...
if ($remember) {
if (empty($user->getRememberToken())) {
$user->setRememberToken($token = Str::random(60)); # stored in plaintext
}
// signed + encrypted, 5 years
$this->getCookieJar()->forever(
'remember_'.'session'.'_'.sha1(static::class),
$user->id.'|'.$user->remember_token().'|'.$user->password()
)
}
// ...
}
See Illuminate/Foundation/Auth/AuthenticatesUsers.php
, Illuminate/Auth/SessionGuard.php
, and Illuminate/Auth/DatabaseUserProvider.php
- Ruby/Devise
while find_first({ :remember_token => token })
token = SecureRandom.urlsafe_base64(20)
args = [user.id, token, Time.now.utc.to_f.to_s] # UNIX timestamp
See devise/models/rememberable.rb
-
Why SHA/HMAC are not suitable for passwords (also this, TL;DR KDFs are much slower)
-
Is base64 URL-safe (TL;DR use
base64url
)
-
Combining bcrypt with other hashes (TL;DR don't forget to base64)
-
Does bcrypt have maximum password length (TL;DR 72 bytes, not 56)
-
Why does
0x00
make bcrypt weaker (TL;DR strings in C terminate on'\0'
)