📷 Photo: Pioneertown Jail, CA by Wendy Bayer
One of the biggest challenges with JSON Web Tokens (JWTs) as an authentication and/or authorization mechanism is the ability to easily and quickly revoke the token if it becomes compromised.
Often Allowlists and/or Denylists are used for ensuring the validity of JWTs, but these required database lookups defeat the purpose of using a JWT over using sessions.
This post presents a rather simple and performant method of revoking a JWT using a counter for each user.
I learned this technique from an Application Security training session we received at CoverMyMeds from Jim Manico and Dr. Justin Collins
In reality it was more of an aside relegated to a single slide that said...
Side note on revocation
Why not associate a counter value with each user
Embed the counter into the JWT, and keep a copy in the database
More lightweight than keeping track of issued identifiersRevoking JWTs for a user account is as simple as incrementing the counter
Validating a JWT requires a check against the stored counter value
A match means that the JWT is not revoked
A stored counter value that is higher than the JWT value means revocation
Here is an implementation of this idea that I developed in a personal project where I used a JWT for authorization.
To add a revocation counter to your application or service's
User
model, you will...
- Add a database migration to add the counter to the
user
database table - Update the
User
model to add validations for the counter as well as methods to check if the JWT has been revoked and to revoke the JWT using the counter - Add tests for the added validations and methods
You will need to add the new JWT counter to your user
database table.
This example is for a PostgreSQL database with an added JWT counter named
authorization_min
. A bigint
is being used to defer/mitigate the
issue of having to reset this counter to avoid a "rollover". You would not
want a revoked JWT to appear valid because its counter value rolled over.
Ideally, you would set up a separate monitoring job to detect when the counter is approaching rollover and to reset it.
Finally, since the approach is to consider a JWT as revoked if the stored counter value is greater than the value in the JWT, we set the initial (default) value of this stored counter to the minimum value for that data type.
class AddAuthorizationMinToUsers < ActiveRecord::Migration[7.0]
def change
# This assume PostgreSQL where -9223372036854775808 is lowest bigint value
add_column :users, :authorization_min, :bigint, default: -9223372036854775808
end
end
You will need to update your User
model for the added JWT counter. Here
validations for presence and integer numericality are added. Methods are also
added to abstract the counter operations of checking if the JWT is revoked and
revoking the JWT.
Again, a JWT is considered revoked if the stored counter value is greater than the value in the JWT. Thus, to revoke a user's JWT, simply increment the stored counter in the database.
validates :authorization_min, presence: true, numericality: { only_integer: true }
def auth_revoked?(auth_value)
authorization_min > auth_value
end
def revoke_auth
update!(authorization_min: self.authorization_min += 1)
end
Here model tests are added for the new counter validations and methods. The validation tests use the Thoughtbot shoulda-matchers gem.
describe 'authorization_min' do
it { is_expected.to validate_presence_of(:authorization_min) }
it { is_expected.to validate_numericality_of(:authorization_min).only_integer }
end
end
describe 'methods' do
subject(:user) { create(:user) }
describe 'auth_revoked?' do
it 'returns true when authorization_min > value' do
user.authorization_min = Faker::Number.number
expect(user.auth_revoked?(user.authorization_min - 1)).to be(true)
end
it 'returns false when authorization_min = value' do
user.authorization_min = Faker::Number.number
expect(user.auth_revoked?(user.authorization_min)).to be(false)
end
it 'returns false when authorization_min < value' do
user.authorization_min = Faker::Number.number
expect(user.auth_revoked?(user.authorization_min + 1)).to be(false)
end
end
describe 'revoke_auth' do
it 'increments authorization_min by 1' do
initial_value = user.authorization_min
user.revoke_auth
expect(user.reload.authorization_min).to eql(initial_value + 1)
end
end
You will need to add the current value of the user's stored JWT counter
to the JWT when you issue it to that user which is specific to your
JWT implementation. Here in this post, this value is named auth
.
Now that you have the JWT counter added to the User
model and the
user's JWT, you must add the logic to check this counter.
If you are using the JWT for authentication, you will want to check
this counter as part of your login processing. If you are using the
JWT for authorization, you will want to check the counter for each
operation requiring authorization.
Here is an example where a JWT is used for authorization and calls the
auth_revoked?
method added to the User
model...
def authorize_request
decoded_jwt = decode_authentication_jwt(request_authorization_token)
@current_user = User.find(decoded_jwt['user'])
validate_token(@current_user, decoded_jwt)
end
...
def validate_token(user, token)
raise Authorization::Errors::TokenRevokedError if user.auth_revoked?(token['auth'])
end
Additional changes such as implementation of the JWT and revocation of the counter would be specific to your application/service and are not presented here. Hopefully however, this post does give you enough insight and information to be able to implement this approach to JWT revocation in your own project. If you would like to see a full example that uses this counter-based JWT revocation, see my personal project brianjbayer/random_thoughts_api