Generating a signed token. From the username or id and a timestamp a signed token is generated with a secret key. This is the link sent to the user. When the user submits the token is decrypted back and the user and timestamp retrieved and validated.
You are talking about secret keys, "signed" tokens and "decryption" (and later, "salt") in a very loose manner that suggests you are likely to implement this wrong. What you would want to use in this case is a message authentication code, for example HMAC-SHA256, which your cryptographic libraries almost certainly support already.
You are very wise to be on the lookout for replay attacks. Your idea to send the current password's salted hash as context, while not good if taken literally, is on the right track. What you're missing is that you don't need to put the hash in the URL because you're already storing it; all you need to put in the URL is information that allows your server to retrieve the salted password hash when the reset URL is clicked. The username that you're resetting the password for presumably would do the trick.
Here's a sketch of one possible solution. First, you need a secure secret_key
. One way of doing this that can save you a lot of headaches is to use ephemeral keys—instead of having a long-term key that's used for a long time and that an attacker may still, what you do instead is when your password reset service starts up, it:
- Selects a 128-bit
secret_key
at random. This will only be ever kept in memory—never write it to disk or over the network.
- Select an unique
startup_uuid
. I'd use a random number or UUID.
This means that the secret_key
is only valid for the lifetime of a key reset service process—every time you restart it the key gets discarded and a new one generated. The upside of that is that your service is much more resilient against keys getting stolen. The downside is that reset links generated before a restart will not validate—the purpose of the startup_uuid
is to detect that case and handle it in a friendly fashion.
When asked for a password reset URL for username
, the password reset service does this:
- Choose an expiry timestamp for the reset link, e.g., 15 minutes later the current time.
- Look up the request's
username
's password entry and retrieve its current salted password hash.
- Construct an unambiguous delimited message
reset_data
with the following fields in some fixed order:
- The server process'
startup_uuid
;
- The request username;
- The selected expiry timestamp;
- The user's current password hash.
- Compute
reset_token = HMAC-SHA256(secret_key, reset_data)
- Construct a reset URL with these values as parameters. (NOTE: the password hash is not a parameter in the reset URL!)
- The username;
- The server startup timestamp;
- The expiry timestamp;
- The
reset_token
.
- Return that constructed URL.
When you receive a request at the password reset URL's endpoint:
- If the request's
startup_uuid
is not equal to the server's, then the service has been restarted since the link was generated. We no longer have the old secret_key
and thus cannot verify that request. The user will have to redo the password reset process.
- If the current time is later than the URL's expiry timestamp value, the link is expired, so don't accept it.
- Look up the salted password hash for the request's username.
- Construct an unambiguous delimited message
reset_data
as above.
- Verify that this
reset-data
message matches the reset_token
that's included in the URL. Make sure to do this safely with a constant-time equality comparison.
- If the verification succeeds, go ahead and reset the user's password.
This protocol has these virtues:
- The secret keys are ephemeral and only ever kept in memory, so even if somebody somehow steals a key it's only good until the next restart.
- The hash of the password to be reset is used as an input to HMAC. If an user tries to reset a password that's already been reset or otherwise changed, the reset token verification will fail.
- Unlike your proposal, the hashed password is never disclosed.