A REST API lets other programs create, read, update and delete your data over HTTP. OAuth 2.0 decides who may call it: callers present an access token, and your API checks that token before doing anything. This guide builds a small CRUD API with Node.js and Express, then protects it with OAuth 2.0 access tokens the way current practice recommends.
Build the API with Express and let a dedicated authorization server (an identity provider such as Keycloak, Auth0, Okta or Microsoft Entra ID) issue the tokens. Your API is the resource server: it verifies each JWT access token's signature, issuer, audience, expiry and scopes with a maintained library such as jose. Use the Authorization Code flow with PKCE for users and Client Credentials for machine-to-machine calls; the old implicit and password grants should not be used.
1. REST and OAuth 2.0 in two minutes
A REST API maps HTTP methods to actions on resources:
| Method and path | Action | Success code |
|---|---|---|
| POST /items | Create an item | 201 Created |
| GET /items/:id | Read one item | 200 OK |
| PUT /items/:id | Replace an item | 200 OK |
| DELETE /items/:id | Delete an item | 204 No Content |
OAuth 2.0 has three roles you need to keep apart:
- Client: the app calling your API, such as a web app, mobile app or backend job.
- Authorization server: logs users in, and issues access tokens.
- Resource server: your API. It never sees passwords; it only checks tokens.
The most common mistake is to build all three into one Express app. Writing a correct authorization server is hard and security-critical, so use an existing one.
2. Choose the right flow
| Who is calling | Flow to use | Notes |
|---|---|---|
| A user in a browser or mobile app | Authorization Code with PKCE | PKCE is now recommended for every client, not only mobile apps |
| A backend service or cron job | Client Credentials | No user; the service authenticates with its own ID and secret |
| A TV or CLI with no browser | Device Authorization | The user approves on another device |
The implicit grant and the resource owner password grant are no longer recommended. The OAuth 2.0 Security Best Current Practice (RFC 9700) and the OAuth 2.1 draft drop them, so don't build new code on either.
3. Set up the project
Use a current LTS release of Node.js (Node.js 22 or 24 in 2026) and Express 5.
mkdir items-api && cd items-api
npm init -y
npm pkg set type=module
npm install express joseExpress has its own JSON body parser (express.json()), so the separate body-parser package from older tutorials isn't needed.
4. Build the CRUD API
This version keeps data in memory, which is enough to learn with. A Map is used instead of a plain object, so a client can't overwrite built-in properties by sending an ID such as __proto__.
// server.js
import express from 'express';
import { randomUUID } from 'node:crypto';
import { requireAuth, requireScope } from './auth.js';
const app = express();
app.use(express.json({ limit: '100kb' }));
const items = new Map();
app.post('/items', requireAuth, requireScope('items:write'), (req, res) => {
const { name } = req.body ?? {};
if (typeof name !== 'string' || name.length === 0 || name.length > 200) {
return res.status(400).json({ error: 'name must be a string of 1-200 characters' });
}
const id = randomUUID();
items.set(id, { id, name, owner: req.auth.sub });
res.status(201).location(`/items/${id}`).json(items.get(id));
});
app.get('/items/:id', requireAuth, requireScope('items:read'), (req, res) => {
const item = items.get(req.params.id);
if (!item) return res.status(404).json({ error: 'not found' });
res.json(item);
});
app.put('/items/:id', requireAuth, requireScope('items:write'), (req, res) => {
const item = items.get(req.params.id);
if (!item) return res.status(404).json({ error: 'not found' });
const { name } = req.body ?? {};
if (typeof name !== 'string' || name.length === 0 || name.length > 200) {
return res.status(400).json({ error: 'name must be a string of 1-200 characters' });
}
item.name = name;
res.json(item);
});
app.delete('/items/:id', requireAuth, requireScope('items:write'), (req, res) => {
if (!items.delete(req.params.id)) return res.status(404).json({ error: 'not found' });
res.status(204).end();
});
const port = Number(process.env.PORT) || 3000;
app.listen(port, () => console.log(`API listening on port ${port}`));The server creates the ID itself with randomUUID(), rather than trusting one from the client, and every input is checked before it is stored.
5. Verify access tokens
Most identity providers issue access tokens as signed JWTs and publish their public keys at a JWKS URL. The jose library fetches and caches those keys and checks the token for you.
// auth.js
import { createRemoteJWKSet, jwtVerify } from 'jose';
const ISSUER = process.env.OAUTH_ISSUER; // e.g. https://auth.example.com/
const AUDIENCE = process.env.OAUTH_AUDIENCE; // the identifier of this API
const JWKS = createRemoteJWKSet(new URL(process.env.OAUTH_JWKS_URL));
export async function requireAuth(req, res, next) {
const [scheme, token] = (req.headers.authorization ?? '').split(' ');
if (scheme !== 'Bearer' || !token) {
return res.status(401).set('WWW-Authenticate', 'Bearer').json({ error: 'missing token' });
}
try {
const { payload } = await jwtVerify(token, JWKS, { issuer: ISSUER, audience: AUDIENCE });
req.auth = payload;
next();
} catch {
res.status(401).set('WWW-Authenticate', 'Bearer error="invalid_token"')
.json({ error: 'invalid token' });
}
}
export function requireScope(scope) {
return (req, res, next) => {
const scopes = String(req.auth?.scope ?? '').split(' ');
if (!scopes.includes(scope)) return res.status(403).json({ error: 'insufficient scope' });
next();
};
}jwtVerify checks the signature, the issuer, the audience and the expiry time. Checking the audience matters: without it, a token issued for a different API would be accepted by yours. Some providers put scopes in a scp or permissions claim instead of scope; check your provider's token format.
If your provider issues opaque (non-JWT) tokens, call its token introspection endpoint (RFC 7662) instead of jwtVerify, and cache the result briefly.
6. Test it
Register the API and a machine-to-machine client with your identity provider, then request a token with the Client Credentials flow. The token URL and any extra parameter (some providers need audience or resource) come from your provider's documentation:
curl -s -X POST https://auth.example.com/oauth/token \
-d grant_type=client_credentials \
-d client_id="$CLIENT_ID" -d client_secret="$CLIENT_SECRET" \
-d scope="items:read items:write"Call the API with the access_token from the response:
curl -s -X POST http://localhost:3000/items \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"First item"}'A request with no token should return 401, and a token without the right scope should return 403. Test both.
7. Before you go live
If you truly need to run your own authorization server, use a maintained, certified implementation such as Keycloak or oidc-provider, not hand-written token endpoints.
8. Running this on Domain India
| Where | How the API runs | Good for |
|---|---|---|
| cPanel or DirectAdmin shared hosting | Setup Node.js App (cPanel offers Node.js 20, 22 and 24); the app runs behind Apache | Small, low-traffic APIs next to an existing site |
| App Platform | Node.js apps are detected automatically; other stacks need a Dockerfile | Production APIs with deploys from Git or CI |
| VPS | Self-managed, full root access; you run Node.js, a process manager and a reverse proxy | Full control and your own identity server |
On shared hosting, jailed SSH access is available on request (it is off by default; ask support), and your app shares the account's CPU and memory limits. See deploying a Node.js app on shared hosting and getting started with the App Platform. App Platform prices on the card are live and exclude 18% GST.
- 512 MB RAM per app
- 1 vCPU
- 5 GB NVMe SSD
- PostgreSQL Database
Do I need to build my own OAuth 2.0 server for my API?
Usually not. Your API only needs to verify access tokens. Let an identity provider or a maintained server such as Keycloak issue them, and verify them in the API with a library such as jose.
Which OAuth 2.0 flow should a single-page app or mobile app use?
The Authorization Code flow with PKCE. The implicit grant is no longer recommended for any client.
Which flow should a backend service use to call my API?
The Client Credentials flow. The service authenticates with its own client ID and secret and receives an access token without any user involved.
What should my API check in a JWT access token?
The signature against the provider's published keys, the issuer, the audience, the expiry time, and the scopes the endpoint needs. Reject the request with 401 for a bad token and 403 for a missing scope.
Is express-oauth-server still a good choice?
For new projects, no. It turns your API into an authorization server you have to secure yourself. Use an identity provider for tokens and verify them in your API with a maintained library.
Can I host a Node.js API on Domain India?
Yes. Small APIs can run on cPanel or DirectAdmin shared hosting through the Node.js app tool, production APIs suit the App Platform, where Node.js is detected automatically, and a self-managed VPS gives full control.
Ready to deploy your API? Compare App Platform plans, run it on cPanel hosting for small projects, or choose a VPS for full control.
Node.js apps are detected automatically; other stacks deploy with a Dockerfile.
See App Platform plans