Most APIs leak in the same handful of ways: missing audience checks, scope rules scattered across handlers, JWT validation that drifts out of date, tokens issued for one service quietly accepted by another. Each one is small on its own, and each one ends up in a postmortem.
This post walks through the patterns that prevent those failures — token validation done right, per-API audience isolation, route-level scope enforcement, and server-side token forwarding from a front end. The code samples come from MonoShop, a small reference repo we put together to make the patterns concrete, but the ideas apply to any Node.js API sitting behind an OAuth/OIDC provider.
Treat Each API as Its Own Resource
The first design decision in API authorization is granularity: what is a token actually issued for? The right answer is almost always each API, not "the platform."
If your billing API and your admin API both accept "any signed token from our IDP," they share a trust boundary. Compromise one and you've compromised both. The fix is to register each API as a separate protected resource with its own audience identifier — typically a URL string — and reject anything that doesn't match.
The MonoShop repo demonstrates this with two services:
| Service | Audience | Required Scope |
|---|---|---|
| Product API | http://api.monoshop.com/products |
read:products |
| Invoice API | http://api.monoshop.com/invoices |
read:invoices |
A token minted for one will not validate against the other, even though both trust the same identity provider. The audience claim is just a string, but it's the line between "user signed in" and "user can hit this specific service." Treat it like a service boundary, because it is one.
Token Validation, Done Right
Validating a JWT properly is more than calling jwt.verify. You need the right signing keys (fetched from the provider's JWKS endpoint), a strategy for key rotation, issuer verification, audience verification, expiry and clock-skew handling, and scope checks tailored to each route. Get any of it wrong and you've either broken legitimate users or, worse, accepted tokens you shouldn't.
This is the layer where silent bugs live. Most identity providers ship middleware that handles the pipeline end-to-end, and there are certified OAuth libraries that do the same — reaching for one of them is the simpler and safer default. Here's what that looks like — the entire Product API in MonoShop:
import { protectApi } from "@monocloud/backend-node/express";
import express from "express";
import { products } from "./data";
const app = express();
const protect = protectApi();
app.get("/products", protect({ scopes: ["read:products"] }), (req, res) => {
res.json(products);
});
app.listen(4001);
The middleware fetches the JWKS, verifies the signature, checks the issuer, validates expiry, enforces the configured audience, and confirms the token carries the scopes the endpoint requires.
If you're using opaque tokens instead of JWTs, it introspects the token against the provider's introspection endpoint and runs the same checks against the response, including confirming the token hasn't been revoked. Your handler runs only when all of that passes.
Audience Isolation Should Be Configuration
Once each API is its own resource, enforcing the boundary should be a config value, not application code. A typical setup is one environment variable read by the middleware at startup:
MONOCLOUD_BACKEND_AUDIENCE=http://api.monoshop.com/products
The check happens before any of your code runs, which means you can't accidentally forget it on a new route. Same user, same issuer, same signing key — wrong audience, no access.
Scopes Belong Next to Routes
Authentication tells you who the caller is. Scopes tell you what they're allowed to do. The cleanest place to express that is right next to the route the rule applies to:
app.get("/products", protect({ scopes: ["read:products"] }), listProducts);
app.post("/products", protect({ scopes: ["write:products"] }), createProduct);
A token without read:products gets a 403 before listProducts is invoked. There's no if (user.can(...)) check inside the handler, no role table to keep in sync — the authorization rule is a property of the route, not a runtime branch.
The general principle: keep authorization declarative and visible at the boundary. If you can't read your route table and tell who's allowed to call what, you've buried the rules somewhere they'll fall out of sync.
Keep Tokens Server-Side
Bearer tokens are credentials. Anywhere you store them on the client - local storage, session storage, accessible cookies — you've extended the attack surface to include XSS anywhere on your front end, plus every third-party script you've ever loaded.
The safer pattern, especially with frameworks that have a server runtime, is to keep tokens in a server-side session and only forward them to APIs from server code. The Next.js front end in MonoShop uses a Server Action to do exactly that:
"use server";
import { getTokens } from "@monocloud/auth-nextjs";
export async function getProducts() {
const tokens = await getTokens();
const res = await fetch(`${process.env.NEXT_PUBLIC_PRODUCT_API_URL}/products`, {
headers: { Authorization: `Bearer ${tokens?.accessToken}` },
});
return res.json();
}
The browser never sees the access token. It calls the Server Action; the server retrieves the token from the session, attaches it to the upstream request, and returns the result. The same shape works whether you have one API or ten — the front end requests tokens for the resources it needs at sign-in time, and the provider mints one access token per audience.
Scaling Out Without New Infrastructure
The pattern above scales naturally. Each new service is another protected resource with its own audience and scopes. Every API:
- Trusts the same identity provider
- Validates its own audience
- Enforces its own scopes
- Has no awareness of the others
There's no shared session store to coordinate, no internal token-exchange dance, no "auth service" in your dependency graph that everyone has to keep alive. Each API is independently protected by stateless token validation. MonoShop ships with two services to show the multi-API shape, but the third would be a copy-paste pointed at a new audience.
Trying It Locally
If you want to see the patterns in action:
git clone https://github.com/monocloud/monoshop-api-demo.git
cd monoshop-api-demo
npm install
Copy each .env.example to .env and fill in credentials from your identity provider (the repo uses MonoCloud — sign up is free if you want to follow along), then:
npm run dev
Open http://localhost:3000, sign in, and the dashboard will call both protected APIs and render their data. To confirm the protection actually works, hit the APIs directly:
curl http://localhost:4001/products
# 401 Unauthorized
Or with a token whose audience or scope doesn't match — same result. The middleware does its job before your handler ever sees the request.
Takeaways
The patterns worth carrying away, regardless of which provider you pick:
- Treat each API as a separate resource with its own audience. Don't share tokens across services.
- Enforce scopes at the route, not in the handler. Your authorization rules should be readable in the route table.
- Keep tokens server-side. If you have a server runtime, forward them from there rather than handing them to the browser.
- Don't roll your own JWT validation in production unless you're prepared to maintain it.
Ship Auth, Don't Build It
API authorization isn't usually where teams want to spend roadmap time. The patterns above are well-trodden, and the implementation is mostly maintenance work that doesn't differentiate your product. Pick a provider, lean on a vetted library, and let the boring parts stay boring.
Want to dig deeper? Start with the MonoCloud Docs, explore the Express Backend SDK, or clone MonoShop and start hacking.
Top comments (0)