OAuth 2.0 and JWT (RS256): an auth setup that stays fast and lets you end sessions

This setup uses OAuth 2.0 with two kinds of tokens. Short-lived JWT access tokens, signed with RS256, are checked without touching the database. Opaque refresh tokens live in the database, so we can revoke them. API checks stay fast, and we can still end a session when we need to.

How it fits together

We use two tokens so we get both speed (no database call on every request) and control (the ability to end a session).

PartTechnologyWhat it doesWhy it matters
Access tokenJWT signed with RS256Sent with every API callChecked by its signature alone, with no database lookup
Refresh tokenRandom opaque stringUsed to get a new access tokenStored in the database, so we can revoke it at any time
Auth serverexpress-oauth-serverHandles the OAuth 2.0 flows and issues tokensStandard behavior, with our own model plugged in (see below)
DatabasePostgreSQL with PrismaStores users and refresh tokensThe single place that decides whether a session is still valid

Why RS256? We sign each JWT with a private key, and anyone can verify it with the matching public key. Other services can check tokens without ever holding the signing key. This works well when one central auth service issues tokens and several other services only need to verify them.

Why two tokens?

Access token: short-lived and checked without the database

  • Format: a JWT with claims such as sub (the user), exp (when it expires), and scope.
  • Signing: signed with RS256 using the private key. Services verify it with the public key.
  • Benefit: no database call on each API request. Checking it is just a signature check.
  • Lifetime: short, from about 15 minutes to an hour. That limits the damage if a token leaks.

Refresh token: long-lived and revocable

  • Format: a random string with no meaning of its own.
  • Storage: saved in the database and linked to the user, and optionally to the client app or session. Store a hash of the token rather than the token itself, so a database leak doesn't hand out working tokens.
  • Benefit: to end a session (on logout, a password change, or a suspected compromise), we delete or invalidate the refresh token. After that, the user can't get new access tokens.
  • Lifetime: long, from days to weeks. It's only used now and then to get a new access token.

There's one trade-off to know about. Revoking a refresh token doesn't cancel access tokens that were already issued. They keep working until they expire. That's why access tokens need to be short-lived. If you need an instant cut-off, you have to add a check on the server, such as a list of revoked token IDs or a version number stored on the user.

Sign-in flows

Password grant (username and password)

  • Endpoint: POST /v1/signin.
  • How it works: the client sends its client ID and secret (for example, in a Basic Auth header) along with the user's username and password. The server checks them and returns an access token and a refresh token.
  • When to use it: only in your own apps, where the user types their password into your login screen. We wrote our own handler for this flow so we control exactly how tokens are issued.

The password grant is officially discouraged now. The OAuth 2.0 security best-practice document (RFC 9700) says not to use it, and OAuth 2.1 removes it. For new apps, prefer the authorization code flow with PKCE, even for your own apps.

Authorization code grant

  • Endpoints: GET /v1/oauth/authorize, plus a token endpoint where the code is exchanged.
  • How it works: the user is sent to the auth server, signs in, and approves access. The server redirects back to the app with a one-time authorization code. The app then exchanges that code for an access token and a refresh token.
  • When to use it: whenever another app needs limited access to a user's data, such as an app built by another company.

Apps with a backend can keep a client secret on their server and use it for the exchange. Browser and mobile apps can't keep a secret safe, so they should use PKCE instead. With PKCE, the app creates a random value for each sign-in and proves it has that value when it exchanges the code. A stolen code is then useless on its own.

The OAuth model

We plug our own logic into the OAuth library through a model file (oauth.model.ts). These are the main functions:

FunctionWhat it does
generateAccessTokenBuilds a JWT with the right claims and signs it with the private key (RS256).
getAccessTokenVerifies the JWT's signature with the public key and returns the token. No database call.
saveTokenSaves only the refresh token and its details to the database. Access tokens aren't stored.
getRefreshTokenLoads the refresh token from the database so it can be checked and replaced.
revokeTokenDeletes the refresh token from the database or marks it invalid.
getUserChecks the username and password and returns the user (password grant only).
getClientLooks up the client app and checks its secret or redirect URI.
saveAuthorizationCode, getAuthorizationCode, revokeAuthorizationCodeStore, load, and use up the one-time codes in the authorization code flow.

This keeps creating and checking JWTs in one place, and managing refresh tokens in the database.

Security notes

  • Verify every claim, not just the signature. Check exp, iss (who issued the token), and aud (who it's meant for). Accept only RS256, so an attacker can't trick the verifier into using a different algorithm.
  • Access tokens: keep them short-lived. In a browser, keep them in memory rather than localStorage, which any injected script can read.
  • Refresh tokens: send them only over HTTPS. In a browser, put them in an HttpOnly, Secure cookie. Replace the refresh token every time it's used, and invalidate the old one. If an old token is ever used again, treat that as theft and end the whole session.
  • Keys: keep the RS256 private key only on the auth server, and share the public key with the services that verify tokens. Plan for key rotation from the start. Give each key an ID (kid) in the token header, and publish the current public keys, for example as a JWKS endpoint. Keep the old key published until every token it signed has expired.
  • The library: the original express-oauth-server package isn't actively maintained anymore. The community continues it as @node-oauth/express-oauth-server. Check which one you depend on.

Summary

JWT access tokens signed with RS256, combined with opaque refresh tokens stored in the database, give you an auth system that's fast (no database call per request), standard (OAuth 2.0), and controllable (short-lived access tokens and revocable sessions). Writing your own OAuth model lets you decide exactly how tokens look, where they're stored, and how they're revoked.