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).
| Part | Technology | What it does | Why it matters |
|---|---|---|---|
| Access token | JWT signed with RS256 | Sent with every API call | Checked by its signature alone, with no database lookup |
| Refresh token | Random opaque string | Used to get a new access token | Stored in the database, so we can revoke it at any time |
| Auth server | express-oauth-server | Handles the OAuth 2.0 flows and issues tokens | Standard behavior, with our own model plugged in (see below) |
| Database | PostgreSQL with Prisma | Stores users and refresh tokens | The 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), andscope. - 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:
| Function | What it does |
|---|---|
generateAccessToken | Builds a JWT with the right claims and signs it with the private key (RS256). |
getAccessToken | Verifies the JWT's signature with the public key and returns the token. No database call. |
saveToken | Saves only the refresh token and its details to the database. Access tokens aren't stored. |
getRefreshToken | Loads the refresh token from the database so it can be checked and replaced. |
revokeToken | Deletes the refresh token from the database or marks it invalid. |
getUser | Checks the username and password and returns the user (password grant only). |
getClient | Looks up the client app and checks its secret or redirect URI. |
saveAuthorizationCode, getAuthorizationCode, revokeAuthorizationCode | Store, 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), andaud(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,Securecookie. 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-serverpackage 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.