Hacker News new | ask | show | jobs
by arkadiyt 2734 days ago
Hard to say without more concrete details, but if I had to reply in broad strokes:

- For web, user/pass login exchanged for plain session cookies. Should be marked httpOnly/Secure, and bonus points for SameSite and __Host prefix [1]

- For web, deploy a preloaded Strict-Transport-Security header [2]

- For api clients, use a bearer token. Enforce TLS (either don't listen on port 80, or if someone makes a request over port 80 revoke that token).

- If you go with OpenID/Oauth for client sign-ins then require https callbacks and provide scoped permissions.

- Don't use JWT [3]. Don't use CORS [4].

Again these are broad strokes - if you gave more information you'd get a better response.

[1]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Se...

[2]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/St...

[3]: https://en.wikipedia.org/wiki/JSON_Web_Token

[4]: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS

10 comments

> if someone makes a request over port 80 revoke that token

I really like this trick! Not only do you now have a log of "shady stuff" happening, but you've gotten rid of the now compromised tokens instantly!

Can you elaborate on what this means? I'm not too familiar with authentication flows. Why would someone make a request over port 80? Why is that bad?
To kind of combine the others comments and add some more depth:

HTTPS runs over port 443, and port 80 is normally for HTTP only.

Most people setup their webservers to serve HTTPS over 443 and redirect port 80 to 443 so anyone that sends something to `http://example.com` automatically gets forwarded to `https://example.com` (and then the browser can tell them to ONLY use the HTTPS version from that point forward)

This is a generally "acceptable" tradeoff between usability and security.

But when you control the API and the client, you can know that you will never send credentials over HTTP, only HTTPS. So you should have nothing coming over port 80.

Then, with a simple script, you could set it up so that it will accept everything on port 80, and if it includes a token of some kind from your app, it logs the request, then marks the token in your database as "expired" so it can no longer be used anywhere (even on port 443).

There's a kind of attack sometimes called "SSLStrip" which can block all requests for someone to an HTTPS version of the site, and in many cases can trick the client into trying the HTTP version if the HTTPS fails. This kind of thing would not only stop that attack, but it would also log it and ensure that any tokens sent that way would be instantly expired, so that the attacker (who saw the tokens when the client tried to send them) can't use them.

There are other reasons too. It will notify you of any developer-mistakes that are sending credentials over HTTP (it's a one letter difference, and despite the best efforts sometimes these kinds of things slip into a codebase as typos or a dev just not thinking), and it can help you tell "scanning" traffic from "normal" traffic (since nothing in your app will ever talk to port 80, so everything on port 80 is "bad" and can give you some clues into what attackers are trying on your network).

And the best part is that it's easy! I could probably throw something together for our API servers that does this in a day or so start-to-finish. That's a REALLY good ratio between time spent and security benefit that you normally don't get!

Im guessing a downgrade attack where a mitm convinces the client to use plaon http so they can read the plain text credential.
Requests sent to port 80 will (usually) be unencrypted HTTP traffic, hence revealing the secret token to anybody listening in between the client and the server. Someone may accidentally send an HTTP request either by typo or lack of knowledge.
Isn't JWT also a type of bearer token? Could you please provide some more detailed arguments about why JWT shouldn't be used other than linking its wikipedia article?
JWT is fine if "revoke" isn't in your vocabulary for the service. If you do need to revoke tokens, JWT becomes a racey contraption that requires synchronizing and looking up state on every request, the avoidance of which was the main reason to use JWT in the first place.
If you use a realtime transport like WebSockets, you could keep automatically re-issuing a fresh JWT with a very short (e.g. one minute) expiry every 50 seconds; just push them at an interval to authenticated clients. That way your banning mechanism would only have a one minute a delay. No need to revoke tokens; just let them expire.

In such a system, a user would be logged out after one minute of closing the connection... Probably good enough for online banking. In a way, this is safer than standard sessionId-based auth because once you've issued the token, you don't need to worry about scenarios where the user has gone offline suddenly.

There are very few systems that need banning with down-to-the-millisecond accuracy.

What you’ve described is a workflow that will technically work. But I’m weakly confident it’s still suboptimal for most use cases.

How will you deal with the usability problem introduced by expiring all sessions for users who are offline (closed tab, spotty internet connection, etc) for at least 50 seconds? It actually seems like you really should be wondering how to interpret users being offline temporarily.

You could just accept this as working as intended, but you don’t need to accept it if you just use normal session IDs. Is your application really so latency sensitive that it can’t tolerate a DB lookup? What is an example workflow in which users on a web or mobile application cannot be tolerably authenticated with standard DB lookups?

You can also use a refresh token, but this brings you back to the revocation problem, but with longer term tokens. Likewise, there is a material difference between millisecond revocation and sub-minute revocation. There are good reasons to care about sub-minute revocation.

You don't need JWT in this case. You can use a normal token with short expiry and some mechanism to keep it fresh as long as the user doesn't exit the application.
JWT is simpler to implement and more scalable than the sessionId approach so why would you use the more complex solution to get an inferior result?

With JWT, you only need to do a single database lookup when the user logs in with their password at the beginning... You don't need to do any other lookup afterwards to reissue the token; just having the old (still valid but soon-to-expire) JWT in memory is enough of a basis to issue a fresh token with an updated expiry.

It scales better because if you have multiple servers, they don't need to share a database/datastore to manage sessionIds.

I don't know what stack you're working with that makes you say re-issuing JWT every 50 seconds over WebSockets is simpler to implement than the session ID approach people have been using for 20+ years :)
With WebSockets, you also have just one lookup when opening the connection. No scalability issue here.
Same goes for any signed token scheme. You can still revoke JWTs if you give them an ID and keep a revoke list somewhere. Though as you said most use these to avoid datastore lookups. It's a trade off. Either time-limit signed tokens that can't be revoked with benefit of no lookups or implement revokation.
> You can still revoke JWTs if you give them an ID and keep a revoke list somewhere.

You don't need the ID. You can simply store the token's signature. In fact, some implementations store the whole JWT to avoid roundtrips to the auth service, and revoking the token is just a matter of flipping an attribute in the database.

This kind of comment might make one wonder why not just use a sessionId to begin with but JWT in this case is still useful in a microservice arch because a token which has been revoked from one high security microservice may still be valid for lower security microservices: It gives each microservice the option of deciding what kind of security they need to provide... They may not need any revocation list; maybe the JWT on its own is sufficient; they just keep accepting the token until it expires naturally.

The token expiry determines the baseline accuracy of banning across all services.

> This kind of comment might make one wonder why not just use a sessionId to begin with

JWT and sessionIds are totally different beasts. JWT are used per request, are designed to expire and be refreshed, are specific to each individual endpoint and store authorization info in a specialized third party service.

"No revocation" is a dangerous constraint to have in an authentication session. What happens if a user's token is compromised? You have to either wait for the token to expire (if you implemented expiry) or log out every single user.
That's exactly the trade-off. I'm not going to say it's a big enough negative to dismiss using the stateless signed token scheme because it depends on the needs of the application.

But either way, if you really can't afford a database or cache layer lookup to see if a token is still valid, then you accept that by using a bearer token, that is only validated by signature alone, that it is possible a user will have their session hijacked without possibility of revokation.

The usual way this is mitigated is by use of a small expiry time (I've commonly seen <=5 min) and a revokable refresh token. This still gives a hijacker a possible 5 minutes (assuming 5 minute expiry) if a user revoked the refresh token, but it does mitigate the damage while still reducing DB lookups since you only do a lookup in token refresh. Hope that clears things up. Again your application needs should drive these decisions.

Indeed. However, this is just a building block and not a library solution. Combined with a revocation list you are good. And use something like OpenPolicyAgent to implement it, adding a lot of other possibilities as well.
It's a common practice to add expiry timestamp for such tokens so each token will expire after certain interval.
That's dandy, but it's a solution which is neither standardized nor native to JWT. It's also a weak, passive form of revocation instead of a robust, active form. How do do you revoke a token prior to timestamp expiry?

In 2018 it is fully possible to use authentication libraries which natively support granular control for things like revocation using strong, turnkey cryptography. I would argue most people who think they should be using stateless and signed sessions for e.g. performance are heavily discounting the revocation liability and neglecting to optimize their lookups sufficiently (such as by caching).

Revoking a bearer token is trivial and in all likelihood, revoking tokens is a very infrequent event. In most cases it is such a rare event that you can usually commit your blacklist to source code.

If not a service to validate tokens against a blacklist is again trivial and will scale to all but the top 0.1% of organizations. And it only needs to be in the blacklist long enough for the period until the token expires.

Yes, jwt is not ideal. But this talk that you should never ever use them and your service will be immediately hacked etc is silly internet bandwagoning.

For a huge percentage of services jwts are just fine. Anyone reading this, please do not over think this advice and just ship with jwts if that is what you have.

> Yes, jwt is not ideal. But this talk that you should never ever use them and your service will be immediately hacked etc is silly internet bandwagoning.

I never said you should never ever use JWT or that your service will be hacked if you do so. In fact, if you kindly reread what I wrote you'll see that I explicitly mentioned there are legitimate use cases for JWT. I am specifically refuting the use of JWT as an authenticated session management system.

> Anyone reading this, please do not over think this advice and just ship with jwts if that is what you have.

This is poor advice.

1) Authentication is sufficiently solved for most workflows and applications that you can use turnkey solutions for more secure and more performant authentication than JWT.

2) What exactly is the scenario you envision in which JWT is all someone has? Do you mean they're forced to use stateless session management, or that JWT is literally all they can do for authentication because nothing else is available?

> That's dandy, but it's a solution which is neither standardized nor native to JWT.

That statement is false.

JWT were specifically designed to store a payload JSON object which among the many standardized fields include the token's expiry time, and JWT were specifically designed with a workflow which includes not only client-side token refreshing but also server-side token rejection that triggers client-side token refreshes.

In fact, JWT token refreshes and token rejections feature in any basic intro tutorial to JWT, including the design principle that tokens should be discarded and refreshed by the client as soon as possible and also the use of nonces.

No, it's not false. Tutorial "best practice" guidance does not constitute a standard. JWT does not provide native revocation. Neither refreshes nor expiry constitute revocation. Revocation is an active state change, not a dead man's switch.
I'd argue that people who think they should be using caching are heavily discounting the consistency issues they will encounter (no doubt at the least convenient time), and may well end up reintroducing the same problem they're trying to solve. If you have revocable tokens accessed via an authentication lookup cache with a 5-minute expiry then you've spent a lot of time and engineering effort to have exactly the same problem as if you had non-revocable JWTs with 5-minute expiry.
> In 2018 it is fully possible to use authentication libraries

So — getting back to the OP — which libraries?

NaCL, Fernet or Paseto.
you can also blacklist existing tokens - but that's not without it's own drawbacks https://auth0.com/blog/blacklist-json-web-token-api-keys/
I thought the problem with JWTs was the whole “stateless JWTs”.
Tptacek shits on them every time it comes up. Unfortunately I can never quite comprehend what he says to do instead.
He suggests KISS: you can probably get away with plain old server-side auth, and if you really need client-side tokens, use something simple that just encrypts and signs them: https://news.ycombinator.com/item?id=13612941#13615634
> Something simple that just encrypts and signs them

Like JWT?

I feel like that argument goes around in circles.

> I feel like that argument goes around in circles.

I feel that the problem is that some users are talking about stuff they know nothing about, but still feel compelled to be very vocal and opinionated.

I can elaborate a tiny bit. It's been mostly a rocky road in library development as well as some confusion in the jwt specification. Basically the JWT spec is poorly designed for lay-programmer use and some folks are implementing the spec wrongly or are just configuring their systems that use properly-implemented libraries in dangerous ways. For instance you need to choose the algorithm carefully and then be careful not to accept any other specified algos as it can cause some interesting attacks (specifying symmetric algorithm when the token was meant for asymmetric ones can lead to valid signing using the public key if the system allows it). Also Technically a user can specify a "None" algorithm that doesn't do payload verification, which tbh all backends SHOULD drop tokens specifying this.

JWTs as bearer tokens aren't bad in their own right, but if you aren't careful you can screw yourself and therefore many security experts avoid them for use in securing systems. Plus a lot of people mistake it for an encrypted token which it isn't. You can imagine how bad that can get.

Tbh I'm with the parent commenter. I avoid them, but if you avoid common pitfalls they should work for your system no problem.

I'm on mobile and can't be arsed to gather sources, but you can search the claims I made and you'll see several articles about these problems. There's even a defcon talk about a new proposed standard (called Paseto I think) that starts by highlighting the major issues with JOSE and JWT specifically.

Also (separate post for separate replies), why not use CORS? this is the first I'm hearing about this. SPA websites often use things like JWT and CORS (ours included)
The author hasn’t clarified yet, but I suspect what they’re referring to is the fact that CORS does not support granular access control. If you make something public under CORS, any client can retrieve the resource if no other authorization or authentication check is in place. It’s not a system of authentication, it’s a system of authorization - specifically, for authorizing hosts to request resources which normally wouldn’t be authorized to do so under same origin policy.

As a concrete example: people occasionally misuse the Origin header, thinking that they can use it as a form of client authentication. The idea is that any client request from a non-whitelisted origin will fail. But any user can spoof their own Origin header, and the Origin header is primarily intended to protect users from making CORS requests they didn’t intend (because in most cases an attacker cannot coerce a browser to forge a header).

CORS is not a tool to turn resources private, but to protect the browser (not the server's content) from cross domain requests.
Exactly, the attacker can always not use the browser and emulate a browser request if motivated enough.
Yes, that's precisely why CORS is a poor fit for authentication :)
Sure, but I don't see why the tip in OP is "don't use CORS". To me that implies there is actually something insecure about using it.
Yeah you can use CORS securely, there are just pitfalls to look out for.
Wondering about the same thing.
Some people have performance concerns with CORS is the main reason I believe. The overhead is an extra round trip.
I thought the concern was security.

Anyway HTTP2 would hopefully address that (through header compression), and things like zero-RTT TLS and keep-alive further minimize the overhead of an additional request.

Plus doesn't CORS only make preflight requests periodically, not for every request?

I wrote/research a lot about http/2, and even has a small tool for it (https://http2.pro).

Among many things you get from http/2, it cannot eliminate round trip time. Sure, you can keep a connection alive but that's possible with http 1.1 too.

Header compression is HPACK. If the header changes even the slightest bit, it's not cached. Dynamic URLs and headers can easily bust HPACK compression.

Preflights are cached, but because CORS is per-URL caching can be of limited value. If your API uses `/info` and `/edit`, a preflight request has to be made for both (assuming a preflight is necessary). If your application has dynamic URLs (e.g. `/widget/1`, `/widget/2`, etc.) the problem is exacerbated even further.
Isn't the argument against JWT mainly one against using it with weak algorithms, and not something inherent to JWT itself?
No, there are several common arguments against JWT for session tokens. The major one intrinsic to JWT is that it has no system of revocation. Thus instead of using a turnkey solution you need to add an additional layer of state logic to your authentication code if you want to be able to revoke tokens.

It is also correct that JWT 1) supports far more cryptography than is necessary; and 2) supports weak cryptography. You can do better than JWT for session management security and performance merely by generating pseudorandom tokens, associating them to sessions and performing lookups.

More generally speaking: signed, stateless tokens are attractive for a variety of technical reasons. They have legitimate uses. But it's typically a poor security decision to choose them in lieu of revocation, for reasons which are mostly uncontroversial among those who work in security.

> No, there are several common arguments against JWT for session tokens. The major one intrinsic to JWT is that it has no system of revocation.

That's technically false. JWT features multiple systems of revocation, including the use of nonces. Token revocation also features prominently in JWT's basic workflow.

The key aspect is that there is no turnkey implementation, and thus projects need to roll their own implementation, which is frowned upon some developers.

By "intrinsic", I meant precisely that there is no JWT standard which admits native revocation. It naturally follows that no JWT implementation provides a turnkey solution for revocation, because it's not intended to.

JWT is stateless. Revocation is stateful. This is a fundamental tension in both cryptography and access control. Yes, you can retrofit your stateless authentication system with a stateful revocation system. But at that point you're back to square one and the architect working on this should consider why they're undoing the legitimate benefits JWT provides.

Nonce based revocation is an active process. Timestamp expiry is not actually revocation, it's expiry. If your token is compromised prior to expiry, you're out of luck.

> By "intrinsic", I meant precisely that there is no JWT standard which admits native revocation.

That's patently false.

JWT's basic workflow features token refreshes, issue and expiration timestamps, and even nonces, and the backend workflow also supports arbitrary token rejections to trigger token refreshes.

The only aspect of JWT's workflow that is left as an implementation detail is tracking revoked tokens.

> JWT is stateless. Revocation is stateful. This is a fundamental tension in both cryptography and access control.

This sort of argument is ivory tower nitpicking stated disingenuously. JWT include issue and revocation timestamps, which already renders the workflow stateless. The only stateful aspect, which is silly nitpicking and technically irrelevant, is keeping track of nonces and arbitrarily revoked tokens, which require keeping a database to track revocations.

We’ve been going back and forth like this for quite a while, so at this point I doubt I’ll be able to convince you with further explanation. I’m shocked you think it’s “ivory tower nitpicking stated disingenuously” to call stateful tracking of nonces what it is - “stateful.”

I’ll recuse myself from further “nitpicking” I suppose, because this isn’t going anywhere. If you’re interested in actually following why your suggestions are a poor fit for session authentication, I’ll direct you to this flowchart: http://cryto.net/%7Ejoepie91/blog/2016/06/19/stop-using-jwt-....

If you have to track revoked tokens you might as well track active sessions via a session ID.
What’s the difference between a Bearer Token and JWT? I thought they were related?
A bit of misinformation in this side thread.

A JWT token is composed of three parts: a header, payload, and signature.

The problem is that people can put sensitive info in the payload.

None of it is encrypted, it's only signed with HMAC.

Unless you're keeping track of the tokens, once a token is issued it's valid until it expires, due to it's stateless nature.

You can use a JWT as a Bearer token, but since it's only base64 encoded, you can pull out that payload data.

A truly opaque Bearer token will be meaningless to anything other than your server.

Play with the debugger here to see what I'm talking about: https://jwt.io/

A bearer token is opaque. It could be a JWT, it could be something else, depending on the application.
In essence, a JSON Web Token (JWT) is a bearer token. It's a particular implementation which has been specified and standardised.
JWT in particular uses cryptography to encode a timestamp and some other parameters. This way, you can check if it's valid by just decrypting it, without hitting a DB.

Not all bearer tokens have this property.

correct me if i am wrong, but if your backend and front-end run on different ports and you are developing locally using chrome, you have to use CORS to make any non GET requests
What we do is make the frontend server(eg ng serve) proxy request to backend during development.
Does sameSite mean you don't need to worry about anti-csrf tokens, or does it just augment it?
SameSite cookies can eliminate threats from cross domain requests. The strict mode is good enough to even block cross domain regular GET requests too.

However, I wouldn't throw other anti-CSRF measures away because if the attacker can use a stored XSS vuln, they can still make their way to a CSRF as well. Besides that, not all browsers support SameSite flag yet.

If you have an API, you can program your web client like an API client, using bearer tokens for authentication (put them in local storage). It's probably better than cookies.
> It's probably better than cookies.

Why do you think so? I would guess it's a tradeoff about what you think is more likely to happen. XSS or CSRF.

Local storage (and session storage) is vulnerable to XSS. Use a strict content security policy and escape (htmlspecialchars in php and similar functions in other languages) output to combat that.

Cookies are vulnerable to CSRF but can't be read from JS if they are http only (no XSS). To combat CSRF most frameworks already have built-in csrf token support. In case of a API use a double submit cookie. Frameworks like AngularJs/Angular support that out of the box. Also use the secure flag SameSite and __Host prefix [0][1]

[0] https://www.youtube.com/watch?v=2uvrGQEy8i4

[1] the slides from the video: https://www.owasp.org/images/3/32/David_Johansson-Double_Def...

If you mean that HttpOnly for cookies protects against XSS, you are mistaken. The attacker will simply generate requests to the secure endpoints rather than steal the token and use it from somewhere else. HttpOnly does not really protect you against XSS at all.
With "no XSS" I meant a XSS exploit doesn't allow access to the data stored in the cookie. I didn't mean it would protect against XSS. Poor/lazy wording on my part, sorry.

It's true that a attacker simply can generate requests from the XSS'ed browser, my understanding was that the session/token is more valuable to an attacker then only an XSS exploit.

However it seems that someone in the past had the same understanding as me and tptacek disagreed [0]. Oh well. Also reading the linked article [1] (are you the author since you use the same wording?) and it's linked articles it seems both cookies and webstorage are not ideal solutions, but local storage might be preferable since CSRF is not a problem, so one thing less to worry about.

[0] https://news.ycombinator.com/item?id=11898525

[1] https://portswigger.net/blog/web-storage-the-lesser-evil-for...

How is that better than a cookie though? Cookies already provide automatic storage and expiry mechanism. Bonus feature is that they are not accessible by JS code at all, if set httponly flag.
Browsers automatically attach cookies to HTTP requests, opening the door to attacks like CSRF.

The security impact of automatic client-side expiry is tiny, since token expiration must be done server-side anyway.

The HttpOnly flag as an XSS mitigation is almost useless; competent attackers will simply run their code from the victim's browser and session. To protect against XSS, HttpOnly doesn't really help you at all. You should be setting a CSP that prevents inline and 3rd party scripts by default, and whitelist what you must.

Overall, cookies may seem like they have a lot of security features, but in reality they are just patches over poor original design. IMHO, using local storage is probably better, because there's less room to get it wrong.

If you use cookies as a storage mechanism and ignore the cookie header on your backend, you close the door to CSRF attacks.

Here's one glaring problem with local storage: literally any script on your page can access it (for example, vendor scripts). Cookies can only be accessed by scripts from the same domain from which they're created.

That's true, but if you run untrusted scripts on your site it's pretty much game over, anyway.

Why should those scripts limit themselves to stealing tokens when they can send authenticated requests from the browser? To put it another way, why would you care about knowing the root password when you have a way to run a root shell at will?

It's interesting that every time this comes up people talk as though the only vector for a malicious script running on your site is you serving it yourself. A reminder that browsers have a ridiculously lax permissions/security model for extensions which extension developers have been shown again and again to abuse (see the Stylish incident for instance).
How can they send authenticated requests if they can't access your cookies and your backend ignores the cookie header?
> competent attackers will simply run their code from the victim's browser and session

What do you mean? JS even on the same page can't read HTTPOnly cookies. If you are assuming that the browser has been hacked then it is pretty much game over regardless of what you use.

We are talking about XSS, where an attacker can run their JS code on your page. If the attacker can run JS on your page, they can already do whatever your signed-in user can do. No need to read the cookie to make authenticated requests, just like your own code doesn’t need to read the cookie.
This sounds an awful lot like JWT.
In what way?

You simply get a bearer token (non JWT) onto the client and use that from local storage instead of a cookie.

Your JavaScript code then makes api calls using the same bearer pathway as other api clients.

The token can still expire, be revoked, etc. it just prevents you having to handle cookie auth on your api.

You've just described the JWT workflow.
No, they've described a Bearer Token workflow. JWT is a specific method that also (most times) uses Bearer tokens, but it wasn't the first, nor does it have a monopoly on Bearer tokens.

I remember building a service when I was experimenting with web development that used randomly generated tokens in a custom HTTP header, and that is closer to Bearer Token (the standard) than Bearer Token is to JWT.

You're trying to be disingenuously pedantic. It's irrelevant if the workflow is specific to JWT or is shared by other bearer token schemes. The point is that JWT, which is a bearer token scheme, follows that workflow, thus it makes no sense to present that workflow as an alternative to the JWT workflow, as it's precisely the same.
Which is fine to use the same logic, as it’s a robust, easy to understand system. but if you aren’t using JWT then you aren’t using JWT.

The parent comment saying “that sounds like JWT” is implying it’s just as bad or has the same shortfalls as using JWT.

The big objection to JWT is that it's a bearer token with no revocation support. If you're going to implement a bearer token with no revocation support, or a custom revocation implementation, anyway, then the criticisms of JWT apply just as much to the system you're building and you might as well just use JWT.
Where does Amazon’s sigv4 fall into play?
Why not JWT?
An oversimplified version of the arguments against JWT for session management (as well as the JOSE specification for signing and encryption) ...

1. The specification has points of ambiguity that have led to a number of flawed implementations. 2. JWT is saddled with unnecessary complexity which also contributes to recurring implementation flaws. 3. JWT increases the complexity of session revocation in contrast to a simple, stateless session ID.

The arguments and counter-arguments are a bit more involved, but be aware that by the time you account for the downsides, you may have negated the value you hoped to gain from stateless web tokens.

If you can use a simple session id, use it. If you need JWT to support external authentication providers, use a short expiration and swap the (fully verified) token for a session id.