
Applications that deal with user accounts need to authenticate (establish the identity of) the user before allowing (authorizing) the user to do different tasks within the application. Passwords are a time-tested and common method used to authenticate users. In typical password-based authentication, user credentials consist of a login ID (username, email, and so on) and a password. These credentials must be securely stored in the database to prevent unauthorized access, and one effective approach is to securely store passwords using PostgreSQL. For every login attempt, the credentials entered by the user are compared with those stored in the database.
When storing user credentials on the database, never (ever) store passwords as plaintext (unencrypted readable text). The opposite of plaintext is ciphertext.
The scope of this article is limited to securely storing user passwords using PostgreSQL. This article does not cover general data encryption in PostgreSQL, nor does it cover encrypting database credentials (i.e., passwords used to log in to the database itself) or connections.
To benefit from this guide, it is necessary to have prior exposure to PostgreSQL. To test the examples, it is assumed you already have PostgreSQL running either on a standalone server or as an instance of Vultr Managed Databases for PostgreSQL.
It is helpful, but not mandatory, to be familiar with the basic concepts of cryptography, like encryption and hashing.
Note that all code samples in this guide are SQL statements. Note also that text strings in SQL statements are enclosed in single quotes.
If user passwords should not be stored as plaintext, they need to be stored in encrypted form. However, encryption algorithms have corresponding decryption algorithms that are used to recover the encrypted data. The goal in encryption is to obfuscate data temporarily and decrypt it back to the original when needed. But this is not desirable in the case of passwords. Storing an encrypted password that can easily be decrypted is of no added benefit. Hence, passwords are not encrypted but hashed.
A hash is a random-looking string that is generated from an input string. The algorithm that generates the hash is called a hash function. It is easy to compute the hash of an input string, but nearly impossible to compute the value of a string given its hash. Hashing is said to be a one-way computation.
Therefore, the most practical attack vector against hashed passwords is brute-forcing. Brute-forcing attempts are typically based on dictionaries. The general idea is to start with a list (dictionary) of the most commonly used passwords and sequentially compute the hash of each possible password until there is a match.
Hashing is a computationally intensive task; this makes brute-forcing difficult. In addition, password hashing functions make use of techniques like key stretching to further slow down brute-forcing attempts. An example of a key stretching technique is to repeatedly hash a string (first compute the hash, then the hash of the hash, and so on).
pgcrypto ExtensionIn PostgreSQL, the pgcrypto extension has the necessary functions for hashing passwords. Since pgcrypto is a builtin extension, you do not need to download or install any additional software for it. Enable the extension:
A hash function always generates the same hash for a given input string. This leads to two main problems:
Both these problems are solved by computing the hash using two input values - the input string (the password) and the salt. The salt is a pseudo-random-generated string that helps ensure the uniqueness of hash values. Even if two users have the same password, their salt values will differ. Hence, all hashes will be unique.
gen_salt() FunctionThe salt is generated using the gen_salt() function. Usually, this function takes one argument - type. The syntax is:
The type argument denotes the type of cryptographic algorithm to use for hashing. It can take one of four possible values:
des - for the Data Encryption Standard (DES) algorithmxdes - for the Extended DES algorithmmd5 - for the MD5 message-digest algorithmbf - for the Blowfish algorithmFor example, to generate a salt using the Extended DES algorithm:
Similarly, to generate a salt using the MD5 algorithm:
Note that the salt also encodes information on the type of cryptographic algorithm to use for hashing, as well as (if applicable) the hashing parameters. Try to generate the same type of salt repeatedly; observe that salts of the same type follow a consistent pattern.
When a user creates a new password (or changes their existing password), the database needs to store (the hash of) the password. The hash is generated using the crypt() function.
crypt() FunctionThe crypt() function is based on the Unix Crypt library. It generates a hash based on the following arguments:
The syntax of the crypt() function is:
The salt_string argument is generated using the gen_salt() function. For example, to use the md5 algorithm to compute the hash:
To simulate the use of the crypt() function without a randomized salt, use a constant string, salt, for the salt. Generate the hash for the password, supersecurepassword:
The above command generates the ciphertext saUkChKIZTKFs. A non-randomized salt (or no salt) leads to predictable hashes, and makes the system vulnerable to rainbow table attacks. Hence, it is necessary to use randomized salts.
Generate a hash for the password string supersecurepassword using, for example, the MD5 algorithm:
Copy the hash value generated by the above command. You will use it in the next section.
When an existing user logs in, they enter their username and password. The server needs to check if the entered password matches the stored password. This is done by calling the same crypt() function with the following arguments:
If the entered password is the same as the actual password, the hash of the entered password matches the stored hash (of the actual password).
The general syntax is:
Suppose the user enters the correct password, supersecurepassword. To test this, call the crypt() function as follows:
The second argument above is the hash generated by the previous command - paste the hash value you had copied earlier. This command outputs the same hash as was generated by the actual password.
Now, suppose the user enters the wrong password, wrong_password. Call the crypt() function as follows:
The output hash value is different from the hash of the actual password.
In other words, calling the crypt() function with a string (string1) and the hash (hash1) of that string (string1) generates the same hash (hash1).
In actual usage, hashes are stored in and queried from tables. The examples in this section show how to do that:
Create a user_account table with three columns - an automatically generated ID, the user name, and the hash of the user's password:
Insert a row of test data into the table:
The above command inserts the username user1, and the MD5 hash of the password, user1_password.
To change the (hash of the) user's password, use the SQL UPDATE command:
To match the entered password with the correct password, call the crypt() function with the entered password and the hash of the correct password as the salt.
If the user attempts to log in with the correct password, user1_new_password:
This should output t, for true, as the value of password_match.
If the login attempt uses a wrong password, user1_wrong_password:
This should output f, for false, as the value of password_match.
When the hash is computed using either the Extended DES or the Blowfish algorithms, it is possible to customize (tune) the number of iterations that the algorithm undergoes to generate the hash. In this case, the gen_salt() function can accept an additional argument, iter_count, for the number of iterations. The syntax is:
In the above statement, type is either xdes or bf.
xdes) algorithm can be an odd number between 1 and 16777215. The default value is 725.bf) algorithm can be an integer between 4 and 31. The default value is 6.If the hash has been generated after N iterations, any brute-forcing attempt also needs to hash password guesses N times. The larger the value of N, the harder it is to brute-force the password hash. However, making the hash computation too slow is impractical for regular use. As a demonstration, hash the password supersecurepassword using the Blowfish algorithm with the default number of iterations, 6:
Notice approximately how long it takes (using the clock on your computer or phone). Now run the same hash function with a higher iteration count:
It takes much longer. If it takes too long, cancel the operation using CTRL + C and retry with a smaller number of iterations.
If you enter an invalid number of iterations, it gives an error like this:
Choosing the number of iterations is a balance between usability and security. The higher the number of iterations, the longer it takes to compute the hash. This makes it less user-friendly, but also increases security by thwarting brute-force attempts. A common recommendation is to choose the number of iterations such that standard server hardware can compute between 4 and 100 hashes per second.
In reality, passwords are a suboptimal solution. Storing passwords on the server (even in hashed form) places the entire responsibility on the application developer and admins. Authentication is a complex topic and it is advisable to use dedicated service providers who specialize in it, like OpenID. Allowing users to log in with a standard set of credentials (such as their Google account) is advantageous both for users as well as application developers.
Nonetheless, password-based authentication is convenient in many cases and is widely used. Hence, due care must be taken to securely store user passwords, and safeguard both the user's security and the application's integrity.
Ultimately, no security is truly foolproof. Greater security measures imply either more complex system design or less user-friendly systems. You need to strike a balance depending on the security needs of your particular application.
0 Comments
Be the first to comment and share your perspective with the community.