CONFIDENTIALITY
You can satisfy these concerns in a standard way via a couple of techniques, though they require a more complex flow:
Use opaque tokens that are not readable by internet clients and do not reveal any sensitive information - see The Phantom Token Pattern for how this works.
In the browser use only secure cookies (SameSite=strict, HTTP Only, AES256 encrypted), which can contain opaque tokens. See the Token Handler Pattern where there is a React SPA you can run and a Node token handler API you can plug in.
Generally these internet credentials behave like session IDs, which are also opaque, but you are using standard OAuth and your APIs still end up authorizing via digitally verifiable JWT access tokens.
TOKEN HANDLER PATTERN
For SPAs the idea is to enable a setup like this, where you plug in (and perhaps adapt) open source token handler components, rather than needing to develop them yourself. This pattern should work with any Authorization Server:

Key benefits are as follows - see these Curity resources for further details:
- SPA uses only the strongest
SameSite=strict cookies, with no tokens in the browser
- SPA can be deployed to many global locations via a Content Delivery Network
- By default, cookies are only used on Ajax requests to APIs, which gives the SPA best control over usability aspects
API FLOWS
When calling APIs the flows then work like this, and typically involve a reverse proxy placed in front of APIs, so that API code remains simple:
Web clients send a secure cookie, and a cookie decryption gets the opaque token. A second plugin then gets a JWT from the opaque token and forwards it to APIs.
Mobile clients send an opaque token to the same API paths, in which case the cookie decryption plugin does nothing. The second plugin then gets a JWT from the opaque token and forwards it to APIs.
Note that the client can still receive an expires_in field if it wants to perform optimizations to check the lifetime of access tokens, but I have always advised against this, and to instead just handle 401s reliably in clients, like this:
private async fetch(method: string, path: string): Promise<any> {
try {
// Try the API call
return await this.fetchImpl(method, path);
} catch (e) {
if (!this.isApi401Error(e)) {
throw ErrorHandler.handleFetchError(e);
}
await this.oauthClient.refresh();
try {
// Retry the API call
return await this.fetchImpl(method, path);
} catch (e) {
throw ErrorHandler.handleFetchError(e);
}
}
}