If you've ever implemented any kind of SSO, you'll have encountered "relay state". Relay state is a parameter you send to your identity party, and they send it back to you without any modification so you can identify the user who just authorized.

It's a pretty common flow, used by Google as well as many other OAuth providers.

When I was a new programmer, I simply sent the user_id of the user as relay state and did a User.find(user_id) to fetch user.

Why do I need to encrypt User ID

So, imagine this, if you signed up for my service, and then later wanted to add your Google credentials, you'd click on a button and it'll take you through the whole authorization workflow, in the end making a Webhook request with relay state as your user id, in plain text.

This was pretty bad as now some user capable of doing Inspect element can change the user_id from their number to any integer and my application would think the other user has authorized. Now they can use "Login with Google" on the sign in page and simply log in as that other user.

Insecure af.

When I was adding a Stride integration to my app which notifies when a certificate is going to expire soon, I had the same problem as their official docs ask us to add a button where you add a relayState parameter. Their application sends us a webhook once the user has added our app. The user is not redirected back to our site, unlike Slack.

So the only way of identifying the user was with that relayState and leaving it to plan user_id would mean if you change the button's value and click on it, you will potentially get notified when other user's certificates are going to expire.

Encrypting the User ID

To combat this issue, I added two little functions to my helper class and called them encrypt and decrypt . The functions looked like this:



# Assuming your Secret Key Base is in Rails.application.secrets.secret_key_base def encrypt text text = text . to_s unless text . is_a? String len = ActiveSupport :: MessageEncryptor . key_len salt = SecureRandom . hex len key = ActiveSupport :: KeyGenerator . new ( Rails . application . secrets . secret_key_base ). generate_key salt , len crypt = ActiveSupport :: MessageEncryptor . new key encrypted_data = crypt . encrypt_and_sign text " #{ salt } $$ #{ encrypted_data } " end def decrypt text salt , data = text . split "$$" len = ActiveSupport :: MessageEncryptor . key_len key = ActiveSupport :: KeyGenerator . new ( Rails . application . secrets . secret_key_base ). generate_key salt , len crypt = ActiveSupport :: MessageEncryptor . new key crypt . decrypt_and_verify data end

How encrypt works

Encrypting a text requires a key and salt . Decrypting an encrypted text requires the same key and salt , otherwise this won't work.

We generate a salt with these lines:



len = ActiveSupport :: MessageEncryptor . key_len salt = SecureRandom . hex len

Once we have our salt, we can use the Rails' secret_key_base as a "key" to generate a cryptographic key.



key = ActiveSupport :: KeyGenerator . new ( Rails . application . secrets . secret_key_base ). generate_key salt , len

Using this key, we create an encryption object crypt



crypt = ActiveSupport :: MessageEncryptor . new key

and then we encrypt our text via:



encrypted_data = crypt . encrypt_and_sign text

Now, we could have simply returned this encrypted_data and provided this as a relay state, but since salt was generated randomly, we would know what it was and so won't be able to decrypt this data.

I used a trick from Rails' has_secure_password and bcrypt , and returned a string which contains salt and encrypted_data as you can see from the last line of encrypt method:



" #{ salt } $$ #{ encrypted_data } "

This returns a string where you have both salt and encrypted_data and the user can't change this to other user's ID without knowing your secret_key_base .

How decrypt works

Once you understand the encrypt method, decrypt id fairly straightforward.

The decrypt method accepts a text parameter, from which it extracts salt and data (or rather encrypted_data ).



salt , data = text . split ( "$$" )

Once you have the salt with us, we create the crypt object, just like we did in the encrypt method:



len = ActiveSupport :: MessageEncryptor . key_len key = ActiveSupport :: KeyGenerator . new ( Rails . application . secrets . secret_key_base ). generate_key salt , len crypt = ActiveSupport :: MessageEncryptor . new key

And then, we decrypt the data:



crypt . decrypt_and_verify data

which we return back from the method.

Conclusion

This is a pretty simple method of encrypting and decrypting a user_id or any other data.

In an ideal scenario, you would create a table and keep a user's token for an application, which you would send as a relayState and use it again to identify the user back. It takes time to get that perfect, so this simple encryption and decryption works amazing to get up and running as quickly as possible.

But in the long run, you'd build out a table to keep all this information secure and more robust. That's what I've done with my app. :)

If you're an expert in security, could you tell me if there's something that I've done here which would make it unsecure? Thanks!