A guide to designing Account Security Mechanisms

The history of the Internet is rife with examples of compromises arising both from poor security hygiene, and also from misguided attempts to "make it more secure" without first considering the implications of changes.

In this post, I'll be detailing some of the decisions you should be making when designing account security and user management functionality.

There's likely little in here that hasn't already been stated elsewhere, but I thought it might be helpful to put it all together in one post.

The post itself is quite long, so headings are clicky links to themselves. For those with limited time, there's a Cheat Sheet style summary towards the bottom.



Account security is an essential component of many, many, systems. Historically, though, amongst some developers there's been a pervasive attitude of "good enough", or worse "I think it makes it more secure, so it must". Some of the worst design decisions are based upon an invalid assumption, whilst others come from an attempt to "layer" security without considering the implications that each layer might have.






More than half the security community has probably written about this at least once.

Passwords should never be stored in their plaintext form, they should be salted and the hashed using a strong hashing mechanism (such as bcrypt or scrypt). Don't use fast hashes such as MD5/SHA1, they're trivially and quickly cracked.

The mechanism used, should always be a one-way hash. Encrypting passwords with reversible encryption is as bad as storing in plaintext (if the attacker has access to your server, they'll likely be able to get the keys). Your system doesn't need to be able to recover the plaintext representation, so there's no justification for storing in a way which would allow an attacker to trivially do the same.

You should always assume that, at some point, someone is going to lay their hands on a copy of your system's authentication database. Using strong hashes increases the amount of time and effort an attacker must go through in order to begin recovering passwords. The more time it takes, the more time there is for you to realise there's been a compromise and take appropriate action.


Complexity Rules

Password complexity rules are one of those things that we, as society, largely assumed were a good thing, but can actually be to the detriment of security.

Complex passwords can be harder to crack, so in an ideal world, complexity rules would be a good thing. In the real world, however, their impact is often that

  • Users get agitated when trying to set a password
  • Users find "creative" workarounds to satisfy the complexity rules (how big an increase in security between these? "Password", "Password1", "Password1!")

A big contributor to the issues with complexity rules is that, across the internet, rules vary wildly. To really top things off, many sites don't think to display their specific complexity rules until after you've failed to live up to them.

After all, who hasn't entered a password like FBOs{2xKW>tA!ZU5 only to have it rejected because "you must use only the following special characters -,.=". The password should be being hashed anyway (see above), so there's very little justification for requiring such a limited set of special characters.

If, for whatever reason, you have to implement complexity rules, there are a number of counter-productive pitfalls which should be avoided:

  • Limiting to a small range of lengths (must be between 6 and 8 characters)
  • Limiting to a small subset of special characters
  • Overly specific rules
  • Being too strict on repeated characters
  • Being too strict on sequential characters
  • Arbitrarily limiting maximum length.

Implementing any (or worse, all) of the above restrictions makes it easier to crack passwords, as it allows the rules/masks passed into a password cracker to easily be tuned to suit your system. We know that any passwords used on your site must meet the requirements, so can exclude anything (that we'd otherwise try) that doesn't meet your ruleset.

This is made even easier where you've arbitrarily limited the maximum length of passwords. If you're hashing passwords, then from a storage perspective it doesn't matter whether my password is 12 or 48 characters. For an attacker trying to brute-force hashes, though, it makes a world of difference.

Most users won't use passwords of that length, but in today's world, there's little reason to set a maximum length anywhere below 60 characters.

You should try and limit your complexity rules to checking the following

  • Length greater than n (where n is a reasonable number)
  • Must contain at least one number and/or special character

You can, of course, still block passwords like "password!" and "letmein1"


Accepted Character Sets

We touched on this above. When designing an authentication system, we want the user to use as secure a password as possible. Some sites use their complexity rules to arbitrarily limit the special characters a user might want to use, but even where this isn't the case, there are sometimes still restrictions which lower the possible number of permutations available in a password of a given length.

Even where additional rules are not enforced, many systems require the characters in a password to be part of the ASCII printable range. So, there are 95 possible characters to choose from.

Whilst that gives a pretty good range of potential password entropy, it can still be improved upon dramatically.

There are some additional considerations to make (see below), but accepting Unicode in your passwords widens the range considerably - particularly where any of your userbase is from a country where certain unicode characters are in common use.

UTF-8 Unicode allows for up to 1,111,998 different characters (though only around 10% of these code points are currently mapped). So, lets look at the number of possible permutations for an 8 character password, assuming that only 10% of the unicode charset can be used

ASCII : 95^8 = 6.6342043e+15
Unicode : 111199.8^8 = 2.3379329e+40

The caveat, of course, being that in neither case are most user actually likely to spread far across the available character sets when compared to others. Particularly with ASCII, users won't stray far from the standard alphabet

But, ask yourself this - when using Unicode, how many users are likely to willingly add an emoji into their password? How many users will use their native alphabet for part of the password, especially if it becomes common for that to be possible?

It's been suggested elsewhere, that from a brute forcing perspective, brute forcing a single unicode character incurs about the same effort as bruteforcing 3 ASCII characters. So it's use increases the work an attacker must do.


There are some considerations to be made, though, if supporting unicode in passwords:

  • Your system needs to support Unicode (duh)
  • Users might encounter difficulties entering their password when travelling (if they'd normally have a key, and now don't, they'll need to know the Unicode key sequence)
  • Spotting obviously weak passwords becomes harder: パスワード 
  • Minimum password lengths need to be calculated correctly, see here for an example of the issues with using byte-lengths to infer character count.
  • You need to decide which encoding to support (UTF-8 is most common)


Ultimately, by the time a password is being written to the database, there shouldn't be any issues though, as the password will have been salted and hashed. So most of the changes you need to make will be in your presentation and processing layers



Account Recovery Functionality

Account recovery can be a tricky area to implement properly. After all, by definition, a user who needs account recovery is unable to use their password to authenticate themselves. By it's very nature, Account Recovery Functionality is a (hopefully) controlled means to bypass security mechanisms.


Security Questions

Security questions are a pretty common mechanism for providing the authentication needed to reset a password.

However, it's a bit of a mis-noma. Security questions do not, generally, exist in order to improve security. On their own, they're nothing more than a means to lower your support overheads (by providing a mechanism to allow users to reset their own passwords rather than having an administrator do it for them).

The biggest issue with security questions, though, tends to be that the questions themselves are crap, and so become the weakest link in the chain.

Some examples common examples of security questions are

  • Mothers maiden name
  • Where were you born
  • Your pets name?
  • Fathers middle name
  • Model of first car
  • First job (or first employer)
  • Favourite food

For the average user, there's a good chance the answer to most (if not all) of these can be found with a quick search for the user's name (or even just their username). Even Financial Institutions still use these pathetic questions as a security mechanism.

Back in 2015, Google did some research into the security of these questions, and had some interesting findings

  • "What is your favourite food?" - there's a 19.5% chance that an English speaker will enter "pizza"
  • For a Spanish speaker, there's a 21% of an attacker getting your father's middle name right in 10 guesses
  • Those who give false answers often give the same answers as others who do the same, so there's actually an increased chance of the attacker getting the correct answer
  • For more difficult questions, the user's themselves don't always set the answer correctly (making it harder for them to answer correctly when they need to). Only 55% could correctly enter the answer to "What was your first phone number?"
  • Where two questions are asked at once (to make it harder for an attacker to guess both), only 59% of genuine users got both answers correct

So, if the security question is too easy, an attacker can google the answers. If we make them harder, a significant proportion of users won't actually be able to remember the answer to use the mechanism when needed.

In other words, Security Questions not only lower security, but they often fail to achieve their stated purpose too.


Sadly, they're still a thing.

Speaking personally, I don't think they should be implemented in a new system (and should be phased out of older designs), but if you are going to implement them, there are a number of considerations you can make to improve things as far as possible

  • Pick sensible questions that can't readily be found on Social Media
  • Consider allowing users to enter a custom question
  • When using to verify a user's identity, always ask more than one question
  • Design your system so that the security question functionality can easily be removed/replaced in future
  • Ensure security questions are only used for one thing, and inform the user (when they're setting them) how and when the answers will be requested.

The last point is particularly important. Some users, if forced to specify security questions, will enter a random string to minimise their exposure to the issues these questions present. If you're going to routinely ask them to answer the questions, then it's only polite to tell them up front.

The UK's National Savings and Investments bank, for example, insists that you set answers to various questions (most of which appear on the list of stupid above).  

What they don't tell you, at the time though, is that they'll ask you to answer 2 of the questions whenever you try to trigger a withdrawal. Rather frustrating if you've not recorded the answers.

What's worse, though, is they're conflating two separate mechanisms, and weakening security as a result.

If, as an attacker, I can find (or guess) the answer to two security questions, I can gain access to an NS&I account. Asking the additional question at time of withdrawal offers no additional protection - I have access to the account and can simply change the questions first (or lookup the answers to whatever questions are then asked, assuming they weren't the same as those I answered to gain access in the first place).

What it does do, is encourage me to use an easy to remember (and more likely, honest) answer to the questions, as I'll need to use them almost as regularly as I do my password. If insisting on using Security Questions, a better solution would have been to ask the questions when storing a bank account to withdraw to (which you can do quite freely, question free).

Sadly, this dangerous behaviour (or at least variations of it) is considered Standard Practice within the financial industry.


One-Time Password Links

A common means of providing account reset functionality (whether in concert with, or as a replacement to Security Questions) is to send a one-time link to the user via an out-of-band channel. Sometimes that's an SMS, but more commonly it's sent to the user's registered email address.

This is quite a good mechanism to use, as it requires the person requesting a reset to prove they have control over another account or device that (theoretically) only the genuine user should have access to.

However, this functionality is not always implemented safely.

Imagine, within your system you have a table to keep track of the reset requests sent

CREATE TABLE resetRequests (
    userid BIGINT,
    requestMade DATE,
    resetUsed DATE,
    PRIMARY KEY(resetid)

With the following data already present

| resetid | userid |      requestMade    |    resetUsed        |
|   1     |   3    | 2017-03-26 10:00:01 | 0000-00-00 00:00:00 |
|   2     |   21   | 2017-03-26 10:05:31 | 2017-03-26 10:08:40 |

It's not an uncommon structure to see (or at least, a simplified version), when people implement their own reset mechanism.

However, how would you formulate the link that'll be sent to the user? Some follow the "logical" route and use resetid, giving a link like http://example.invalid/reset/2.

The problem with this, is it opens you to an attacker guessing reset id's in order to try and take over accounts. Each ID is easy to predict, because they run in sequence, and an attacker may be able to use other clues within your system to guess what the next ID to be issued will be.

Paths used in one-time links should be cryptographically secure to prevent an attacker guessing (or bruteforcing) them (you'll need to adjust the database schema so you've a way to identify the reset request from the path).

One-Time links should also be short-lived, to ensure that an attacker cannot later stumble upon a valid path where a user requested a reset and then did not use it. The lifetime of a link should probably be measured in minutes, but take the potential for delivery delays into account (i.e. don't make the lifetime too short).

To summarise

  • One-Time links should be hard for both a computer and a person to guess
  • One-Time links should have a short validity period

You will probably also want to maintain an audit log of when links were issued, and when they were used, so that you can help trace back any compromises that occur. 


Login Process

Along with password storage, design of the login handling process is where mistakes are most commonly made. The impact of mistakes in this area can vary, ranging from irritating your users to risking compromising their accounts


Paste on the Login Form

Somewhere within your system, you're going to ask the user to authenticate themselves. This is usually via a login form, but the advice here would also apply to any section within the system where you ask the user to re-authenticate (because they're trying to do something considered high risk).

An increasingly common "protection" is to disable clipboard paste on password fields.

This seems to have grown, over time, from two concerns

  1. Historically, paste has been disabled on password verification fields on registration pages, to help ensure the user hasn't accidentally typo'd when typing their password
  2. Malware exists that will steal content copied to the clipboard (including the password they're trying to paste in).

Concern 1 pre-dates the concept of password lockers. In principle, we used to remember secure(ish) passwords and refuse to record them anywhere. However, with the passage of time, it's become clear that most users don't do this, and instead remember poor passwords (or maybe still write them down), and often re-use them between sites.

Password lockers (such as LastPass and KeePassX ) allow very strong passwords to be used (because the user doesn't have to remember each individual one). Use of lockers should absolutely be encouraged, as it leads to stronger authentication credentials.

However, if the user doesn't have a plugin installed for their locker, they'll need to copy and paste the password across (as secure passwords can be hard to type, especially on mobile devices). Disabling paste prevents that, and encourages the user to instead use a less secure password for your service (or, in fact, to use a competing service that doesn't have this restriction).

Concern 2 is valid, to a point.

If the user copies their password to the clipboard, there is a risk that it could be stolen by malware. However, generally, at the point they find out you've disabled paste, they've already copied that password to the clipboard, so if it was going to be stolen, it already has been.

Even if this were not the case, most, if not all, malware which includes clipboard theft also includes a key logging component, so requiring the user to manually enter their password offers little additional protection.

Ultimately, disabling paste on password fields protects against very little, but has the potential to weaken the credentials that users use for your system.


Anti-CSRF Tokens

Cross-Site Request Forgery (CSRF) is a serious issue. Failure to protect against it adequately can allow other sites to trigger actions against your user's accounts. The protections needed aren't strictly limited to the login form, and should be implemented (as a minimum) for any functionality that makes changes to the user's accounts.

Ideally, all sections of your service will include anti-CSRF protection, but this isn't always practical.

As a basic example, imagine that user1 has logged into your service and then browsed to another site. That site includes an image (or script, or stylesheet) anchor with a source of http://yoursite.invalid/closeaccount/confirm. The users browser will attempt to load the URL (expecting an image), and your back-end will trigger any action which the URL requires (which, in this case, we're assuming is to delete the users account).

It's a silly example, in that important actions should never be based on URL alone, but serves to highlight the point. Without CSRF protection, other sites and services can trivially try and trigger actions against a logged in users account. In practice, that also includes pages you'd normally use POST for.

Anti-CSRF protection is trivial to implement. It consists of embedding a random token into important forms, and on submission of the form, verifying both that the token has been received, and that it has the expected value. A new token should be generated for every render of the form. Below is a rough example in PHP7


function genFormTok($frmfields){
    // Generate a token and save to the session
    $_SESSION['token'] = bin2hex(random_bytes(32));

    // Generate a form field
    return '<input name="frmTok" type="hidden" value="' . $_SESSION['token'] . ' />';

function validateFormTok(){

    if (!isset($_SESSION['token'])){
        // Can't be legit, we've not stored a token
        return False;

    $sesstok = $_SESSION['token'];

    // Unset the stored token so a new one has to be regenerated before retrying
    // helps prevent brute-force type attempts

    if (!isset($_POST['frmTok']) || !hash_equals($_POST['frmTok'], $sesstok)){
        // Token either wasn't included, or was incorrect
        return False;

    // Token validated
    return True;

If the token is invalid, you'll want to include a warning to tell the user they should reload the page and try again (as this will generate a new token)

One consideration you should make, though, is whether the login form will be included on pages that you'd expect to be cached (perhaps because you're behind a CDN). In that instance, the token the user submits will likely be incorrect (as the CDN will have cached an older version).

Although it's popular to display the login form on every page, for the reason above, it's often better to have a single dedicated login page (which can be set not to cache) and to simply include a link to it on every page (if desired, you can use return anchors to return the user to the page they left on successful login). 

Note that anti-CSRF tokens exist for one purpose - to catch data that has been submitted via a means other than your "authorised" forms. They don't offer much protection against bots (other than the truly low-hanging fruit), as those will often retrieve a copy of the form (and so have the anti-CSRF token available).


Anti-Bruteforce mechanisms

Pretty much since the dawn of login credentials, there have been bots which will try to brute-force accounts. Essentially sitting with a username and trying password variations, one after the other, in the hope that one of the variations will prove to be correct.

In the past, the approach to this was often to eventually block the IP that the bot was connecting from. In the modern world, though, both cloud computing and botnets are a thing. So although we could still block a source IP, it doesn't offer much protection as the attempts will simply continue from another source.

Rather than trying to target the source itself, we must now apply restrictions to the targeted account instead.

A good, and common method, of achieving this is to use an approach combining graduated back-off and increased complexity;

  • After n (usually 3) unsuccessful login attempts, include a captcha in the login form
  • After another n (usually <=3) lock the account out for 1 minute
  • After another n, increase the lockout time to 2 minutes, then 4 etc

Avoid locking accounts out for an excessive amount of time though, it's OK to increase intervals, but don't make the mistake of allowing your brute-force protections to become a means to deny service to users.

Although, as noted above, it's less effective nowadays, you can also consider introducing a minimum login interval for the originating IP as part of the graduated back-off. So if they've made 7 unsuccessful attempts to login to a single account, not only do you prevent that account from logging in for 2 minutes, but you prevent the originating IP from logging into any account for 2 minutes.

This helps prevent against bots that will try to brute-force a specific account, and then when graduated defences are triggered, will move onto trying another account whilst it waits for the defences on the original account to reset. It's trivially bypassed by a botnet (which can make requests from many thousands of distinct IPs), but helps defend against some of the simpler attacks.


Invalid Password Messages

When a login attempt is unsuccessful, try to avoid providing too much information on why. With the exception of explaining that the Anti-CSRF Token was incorrect, limit yourself to a message that says "Login unsuccessful" or similar

The point here being, you should avoid identifying whether the login failed because the password was incorrect, or the username was invalid. Specifically stating that the password was incorrect allows an attacker to use your login in order to enumerate valid usernames for further attack in future.

A related, but less obvious, vector to fall into here is timing analysis. Imagine the following simplistic code snippet

$u = $userdb->getUserByUsername($_POST['user']);

if (!$u){
    // Invalid username, deny the login
    return False;

// Check the submitted password

$sub = pwd_hash($_POST['pass'],$u->salt);

if ($sub != $u->passhash){
    return False;

return True;

It should be fairly obvious what it does. The issue here, is that if the username is invalid, we return a result more quickly than if it's valid. Depending on the difficulty of the hashing mechanism used, this could be a fairly substantial difference.

Analysis of the time taken to respond would allow an attacker to identify whether the username is valid or not.

There are two common mitigations to this:

  • If user invalid, hash the password either way, or
  • Sleep for a random amount of time in either case

Both have their benefits and drawbacks. Hashing the submitted password either way uses CPU cycles, and might be quite resource intensive, so it potentially provides a means for an attacker to exhaust your resources during an application level (D)DoS.

Implementing a random sleep doesn't incur this risk, but needs to be thought about carefully, as it's still prone to identification by analysis. If you're using a range of 2-30ms for the sleep, but the hashing takes 25ms, there will be instances where the sleep offers no benefit. You need to plan carefully, and balance the needs against not delaying a valid login for too long.



This shouldn't need saying in this day and age, but all the same, credentials should not be submitted over a plaintext HTTP connection.

Always use HTTPS, ideally for the entire site. It's possible to get free certificates (for example, from LetsEncrypt) so there's no longer a guarantee of additional costs being involved. With Server Name Indication (SNI) widely supported, we're also a long way past the era where you needed a dedicated IP to be able to use SSL, so even those on shared hosting should be able to use HTTPS.

The obvious risk to using HTTP is that submitted credentials might be stolen by someone on the network path between you and the user, but there's far more to it than that.

If part of your service is served over plaintext HTTP, then an intermediary can modify and inject content. Verizon were caught doing just this, in order to inject a HTTP header for advertisers to track and identify their users (regardless of the user's browser settings). When large ISPs (who should know better) are willing to do this, you should consider it far from a theoretical risk.

It's not just headers which can be injected into a HTTP stream. Any part of your content can be modified, up-to and including injecting scripts in order to help an attacker compromise a user's account. This may not even be achieved directly - if an ISP is injecting ads (as Comcast has been known to do) - then an attacker simply needs to get their malvertising onto that ISPs ad network.

Suddenly it's not just your own (if any) ads you need to worry about, there's a third party injecting ads that are out of your control (and making someone else money, at that).

Using HTTPS for the entire site avoids all of this, and also helps avoid situations where you've overlooked pages that your users would consider private.


Client-Side Password Hashing

Some people recommend, that during the login process, the user's password should be hashed on the client before submitting to the server.

This isn't always a good or bad idea, but it's important to understand what it actually protects against, and what the potential risks are.

Client-side hashing has one purpose - to ensure that the user's actual password isn't sent over the wire during login. Depending on the mechanism used, the server may or may not know the actual password.

There are 4 common mechanisms

  1. Password is simply hashed. Server doesn't need to know the password, only the hash
  2. Password is hashed with a fixed (usually user-specific) salt. Server doesn't need to know the password, only hash and salt
  3. Password is hashed with a salt generated for that session. Server needs to know the plaintext password in order to generate a corresponding hash for comparison
  4. Store all data encrypted (with a key derived from the user's password) and send it in it's encrypted form to the client for decryption

All have their issues.

In Password Storage above, we looked briefly at safe ways to salt and hash passwords, and fast hashes (such as MD5 and SHA1) aren't among those. If you're looking to hash client side, then there's a good chance you need to do it in Javascript, so will need to identify a strong hashing mechanism that's available (and not too slow).

If you use a fast hash client side, then an attacker capable of intercepting traffic, once again, has a hash which can be brute-forced quite easily. Because your authentication relies on authenticating the hash value, the attacker need not recover the exact password, just one that generates a hash collision (which for MD5 is trivial with today's resources).

With the first 2 options, though, it's important to remember that to an attacker, the actual password no longer matters. All they need in order to log in is the data that hits the wire. By hashing, you've now constrained the password to a fixed length (the length of the hash string) and likely a fixed character set (assuming the hash is hex-encoded).

There's a lot of entropy (16^32 in fact) in a 32-bit hash, but there are still far fewer permutations to try than a password with the same character set, but with a length that could be anywhere between 8 and 32 characters.

For options 1 & 2, the submitted hash will always be the same (as there's either no salt, or the salt is fixed), so although it might take quite some time for an attacker to try different permutations, they can take that time.

It's also worth noting that the submitted hash will still need to be passed through another hashing mechanism on the server, rather than simply being compared to a value stored in the database. In processing terms, that submitted hash is the password, so you would otherwise, effectively be storing passwords in plaintext. 

Mechanism number 3 prevents the password from being sent over the wire during login, and ensures that the hash will change with each submission. But, it requires that the password be sat in a recoverable form on the server. As discussed in Password Storage, this is very, very bad, and completely undermines the protection you were trying to build.

Option 4 is impractical for many services, particularly if you've got to deal with varying levels of javascript support in your user-base. It's only generally used where warranted (such as double-blind storage in a password locker) because of the issues it can present

  • you'll need to re-key all the encryption if the user changes their password
  • If the user forgets/loses their password, the data cannot be recovered

Simply put, for most services, as good as it might sound on paper, client-side password hashing isn't a good fit. It provides the user a small level of protection if they've reused their password on other services, but otherwise offers the average service very little benefit.



2 Factor Authentication

2 Factor authentication is (thankfully) becoming more common. It's based on the idea that you should use 2 different types of information in order to authenticate a user. Generally, that's based on proof of something they know (the password) and proof that they physically have something (a one-time code from a dongle).

This means that even if a users password is compromised, an attacker still cannot log into the service because they don't have possession of the physical code-generator.

There are a variety of 2FA solutions available including the Yubikey (a physical token, plugged into a USB port), Google Authenticator (a so-called soft-token - it's an app on the user's phone). Most of the solutions implement one of a few different 2FA protocols (despite the name Google Authenticator, it implements a standard and isn't tied to Google - there are open source alternatives available that implement the same algorithm and will work just as well).

Ideally, if designing a new system, you want to make it possible to offer support for as many of the common 2FA solutions as possible. User's are more likely to use it if they already have a compatible token that they use with another service.

You should, however, avoid using SMS based 2 Factor Authentication. Because SMS messages can trivially be intercepted and redirected, it's been recommended that that method be abandoned entirely.

Equally important to remember, though, is that 2 Factor Auth tokens are not a replacement for passwords. They are a second factor. A password protects the user in circumstances where their physical token has been stolen, and the token protects them where their password has been stolen.



Cheat Sheet

Element Notes
Security Questions
  • Avoid completely if possible. They pose a significant weakness
  • Avoid questions that can easily be researched
  • Consider allowing users to set custom questions
  • Always ask >= 2 questions at once
  • Don't use the questions for routine operations - limit their use-case to single specific actions
One-Time Links
  • Should be hard to guess/calculate
  • Should have a limited validity period (but account for delivery delays)
  • Should retain a basic audit log of (at least) when they were sent, and when they were clicked
Login Process




There's a lot to consider when designing an authentication system, and as long as this post is, it's far from exhausted. Much of the above constitutes the minimum you should be taking into account. There will always be additional vectors, specific to your service, that also need to be considered.

It should also be just one part of your defences. There's little point in designing a strong authentication mechanism if it later transpires it an trivially be bypassed via some other element of your service. Good design takes time, and you need to think about the risks involved in your service and mitigate as best you can.

Most importantly though, consider the implications of any "improvements" you might be adding. As discussed, both with Security Questions and Client Side password hashing, purported improvements can in fact have the effect of lowering security. Every aspect of your security implementation adds complexity, so care needs to be taken to ensure that complexity doesn't un-necessarily increase your attack surface.