> For the most simple use case of an client auth state; you want to be able to revoke auth straight away if an account is compromised. This means you have to check the auth database for every request anyway, and you probably could have got whatever else was in the claim there quickly.
FWIW, I built a system previously that got around this "having to check the DB on every access to check for revocations" issue that worked quite well. Two important things to realize:
1. Revocations (or what is usually basically "explicit logout") is actually quite rare in a lot of user application patterns. E.g. for many web apps users very rarely explicitly logout. It's even rarer for mobile apps.
2. You only need to keep around a list of revocations for as long as your token expiry is. For example, if your token expiration is 30 mins, and you expire a user's tokens at noon, by 12:30 PM you can drop that revocation statement, because any tokens affected by that revocation would have expired anyway.
Thus, if you have a relatively short token expiration (say, a half hour), the size of your token expiration list can almost always fit in memory. So what I built:
1. The interface to see if a token has expired is basically "getEarliestTokenIssuedAt(userId: string): Date" - essentially, what is the earliest possible issuance timestamp for a token for a particular user to be considered valid. So, revoking a user's previously issued tokens means just setting this date to Now(), then any token issued before that will be considered invalid.
2. I had a table in postgres that just stored the user ID and earliest valid token date. However, I used postgres' NOTIFY functionality to send a broadcast to all my servers whenever a row was added to this table.
3. My servers then just had what was a local copy of this table, but stored in memory. Again, remember that I could just drop entries that were older than the longest token expiration date, so this could fit in memory.
On the off-chance that somehow the current revocation list couldn't fit in memory, I build something in the system that allowed it to essentially say "memory is full" which would cause it to make a call back to postgres', but again, that situation would naturally clear up after a few minutes if revocations went back down and the token expiration window passed.
This sounds more complicated than it actually was. It has the benefits of:
1. Almost no statefulness, which was great for scalability.
2. Verifying a token could still always be done in memory, at least almost. Over a couple years of running the system I actually never hit a state when the in-memory revocation list got too big.
Just seems like a lot of extra fiddly stuff to go wrong for monolithic apps. I get it if you have went all in on microservices as each "client" request can fan out to hundreds of requests, each requiring an auth check.
But still, I'm not sure that I've seen an auth/roles database that couldn't fit (at least) the important stuff itself in RAM itself fwiw. Even 1TB of RAM is relatively affordable (if you are not on the hyperscalers) and you could fit billions of users in that, which at least in theory means you can just check everything and not have another store to worry about.
> You only need to keep around a list of revocations for as long as your token expiry is. For example, if your token expiration is 30 mins, and you expire a user's tokens at noon, by 12:30 PM you can drop that revocation statement, because any tokens affected by that revocation would have expired anyway.
And this sort of thing is basically what redis is for, right? Spin up a docker container, use it as a simple key value store (really just key store). When someone manually invalidates a token, push it in, with the expiry date is has anyway.
Might not even need to store the token itself just a piece of data that is contained in the claims to say the account is in a good state. Any number of tokens then can be issued and the validation step would ensure the claims is correct.
What you describe sounds like it will make any explicit log out action users do on any device turn into a ”log me out from all devices” action, which was probably not at all the user’s intent unless that is the only explicit option you give them.
A "logout" action from the user should just delete the JWT from the device he is using. Asuming the token wasn't compromised, there is no backend work involved.
Is this as secure as doing a blacklist for non-expired tokens? No, it isn't. It is a sane tradeoff between decent security and implementation complexity.
Terminating sessions on other devices is not possible, but another tradeoff is using a "Logout from all devices" mechanism. In that case you just have a global "token not issue before" field, and when you logout from all devices, set that timestamp to the current time (and all issued tokens will fail authentication).
But again, tradeoff. You individual requirements may vary.
> 1. Almost no statefulness, which was great for scalability.
This is called "eventual consistency", it's probably fine in practice but you still do have a lot of state. Personally, if I have any in-application state at all, I would use a sticky cookie on the LB to send each client to the same instance.
This seems like about the best that can be done (well, you could go full Bloom filter to squeeze that revocation list size down even further), but it does seem vulnerable to DoS: Create 10000 accounts and log them all out at the same time to force the server into the slow PostgreSQL mode.
Any system that allows you to create 10000 accounts is already vulnerable to DoS.
Also, as vintermann suggested, you can use a faster, domain-specific database if you're concerned about this becoming an issue. And sometimes edge cases like this aren't worth considering until you hit them.
FWIW, I built a system previously that got around this "having to check the DB on every access to check for revocations" issue that worked quite well. Two important things to realize:
1. Revocations (or what is usually basically "explicit logout") is actually quite rare in a lot of user application patterns. E.g. for many web apps users very rarely explicitly logout. It's even rarer for mobile apps.
2. You only need to keep around a list of revocations for as long as your token expiry is. For example, if your token expiration is 30 mins, and you expire a user's tokens at noon, by 12:30 PM you can drop that revocation statement, because any tokens affected by that revocation would have expired anyway.
Thus, if you have a relatively short token expiration (say, a half hour), the size of your token expiration list can almost always fit in memory. So what I built:
1. The interface to see if a token has expired is basically "getEarliestTokenIssuedAt(userId: string): Date" - essentially, what is the earliest possible issuance timestamp for a token for a particular user to be considered valid. So, revoking a user's previously issued tokens means just setting this date to Now(), then any token issued before that will be considered invalid.
2. I had a table in postgres that just stored the user ID and earliest valid token date. However, I used postgres' NOTIFY functionality to send a broadcast to all my servers whenever a row was added to this table.
3. My servers then just had what was a local copy of this table, but stored in memory. Again, remember that I could just drop entries that were older than the longest token expiration date, so this could fit in memory.
On the off-chance that somehow the current revocation list couldn't fit in memory, I build something in the system that allowed it to essentially say "memory is full" which would cause it to make a call back to postgres', but again, that situation would naturally clear up after a few minutes if revocations went back down and the token expiration window passed.
This sounds more complicated than it actually was. It has the benefits of:
1. Almost no statefulness, which was great for scalability.
2. Verifying a token could still always be done in memory, at least almost. Over a couple years of running the system I actually never hit a state when the in-memory revocation list got too big.