Node.js OAuth Server with JWT: Working Code Under 100 Lines
Most JWT tutorials hand you a token that never expires. Here's a complete Node.js OAuth server using the Client Credentials Grant with proper expiration.
You can build a fully functional OAuth token server in Node.js with fewer than 100 lines of code. No sprawling framework, no passport.js dependency chain: just Express, the jsonwebtoken package, and a database to store client credentials.
The OAuth pattern here is the Client Credentials Grant (RFC 6749, Section 4.4). This is the right choice when one service needs to authenticate with another: no user login screens, no redirects, no browser involved. A client sends its client_id and client_secret, gets back a signed JWT, and uses that token to access protected resources until it expires.
Here's the full architecture:

How the Flow Works
1. Authenticate with clientid and clientsecret
The client application sends its credentials to the token server. Think of client_id as a username and client_secret as a password. In production, you should hash the secret with bcrypt before storing it: the example below uses plaintext for clarity, but never ship unhashed secrets.
2. Sign and return a JWT
Once the credentials check out, the server creates a JSON Web Token containing the client's authorization metadata. The token is signed with a secret key and given an expiration time. This is critical: a JWT without an expiration is a permanent skeleton key. Always set expiresIn.
3. Verify the token on subsequent requests
When the client hits a protected resource, it passes the JWT in the Authorization header. The resource server (or the token server itself) verifies the signature and checks expiration. If valid, the decoded payload tells you who the client is and what they're authorized to do.
The Code
The full source is on GitHub: github.com/grizzlypeaksoftware/accesstokenserver
Before running this, you'll need an SSL certificate. The server is designed to run over HTTPS because sending credentials or tokens over plain HTTP is not an option. If you need a self-signed cert for development, OpenSSL will generate one in a single command:
openssl req -x509 -newkey rsa:4096 -keyout private.key -out SSL.crt -days 365 -nodes
Here's the complete token server:
// dependencies
const express = require('express');
const https = require('https');
const fs = require('fs');
const jwt = require('jsonwebtoken');
// load environment variables (.env file with SECRET and DB config)
require('dotenv').config();
// the authentication model: authenticates a client against the database
const model = require('./models/auth_model.js');
// initialize express with built-in body parsing (no need for body-parser)
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
// root route
app.get('/', function (req, res) {
res.send('TokenServer');
});
// authorize: validate credentials and issue a JWT
app.post('/authorize', function (req, res) {
const clientId = req.body.client_id;
const clientSecret = req.body.client_secret;
model.authorize(clientId, clientSecret)
.then(function (docs) {
const tokenResponse = { success: false };
if (docs && docs.length > 0) {
tokenResponse.client_id = docs[0].client_id;
tokenResponse.success = true;
// sign the token with a 1-hour expiration
const token = jwt.sign(tokenResponse, process.env.SECRET, {
expiresIn: '1h'
});
tokenResponse.token = token;
}
res.json(tokenResponse);
})
.catch(function (err) {
console.error('Authorization error:', err.message);
res.status(500).json({ success: false, error: 'Internal server error' });
});
});
// verify: decode and validate a JWT from the Authorization header
app.post('/verify', function (req, res) {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({ success: false, error: 'No token provided' });
}
// support "Bearer <token>" format
const token = authHeader.startsWith('Bearer ')
? authHeader.slice(7)
: authHeader;
jwt.verify(token, process.env.SECRET, function (err, decoded) {
if (err) {
return res.status(401).json({ success: false, error: 'Invalid or expired token' });
}
res.json(decoded);
});
});
// SSL configuration
const privateKey = fs.readFileSync('private.key');
const certificate = fs.readFileSync('SSL.crt');
const httpsServer = https.createServer({ key: privateKey, cert: certificate }, app);
httpsServer.listen(2600, function () {
console.log('Token server listening on https://localhost:2600');
});
The Auth Model
The code above imports ./models/auth_model.js for credential lookups. Here's a minimal implementation using a generic database query pattern (adapt the query to your database):
// models/auth_model.js
const pool = require('../db'); // your database connection pool
function authorize(clientId, clientSecret) {
return pool.query(
'SELECT client_id, role FROM clients WHERE client_id = $1 AND client_secret = $2',
[clientId, clientSecret]
).then(function (result) {
return result.rows;
});
}
module.exports = { authorize };
In production, store hashed secrets (use bcrypt) and compare with bcrypt.compare() instead of matching plaintext in the query.
What Changed from Common Tutorials
A few things in this implementation are intentional departures from what you'll find in most JWT tutorials:
Token expiration is set. The expiresIn: '1h' option is not optional. Without it, jwt.sign() produces a token that never expires. If that token leaks, it's valid forever. One hour is a reasonable default for service-to-service communication: long enough to avoid constant re-authentication, short enough to limit damage.
No separate body-parser package. Express has included express.json() and express.urlencoded() since version 4.16. The standalone body-parser package still works, but it's unnecessary overhead.
The Authorization header is parsed correctly. The verify route reads from req.headers.authorization (plural) and strips the Bearer prefix. Many tutorials use req.header.authorization (singular, no method call), which silently returns undefined and causes every verification to fail.
Error responses use proper HTTP status codes. A missing token returns 401, not a 200 with { success: false }. Consumers of your API (including load balancers, monitoring tools, and API gateways) depend on status codes to function correctly.
Scaling This to Multiple Microservices
The Client Credentials Grant is purpose-built for service-to-service auth. Each microservice gets its own client_id and client_secret, authenticates with the token server, and includes the resulting JWT in requests to other services.

This gives you a centralized authentication layer without coupling your services together. Add a role or scope field to the JWT payload, and each downstream service can make its own authorization decisions based on the decoded token.
Security Considerations
This example gets a working token server running fast. Before you take it to production, address these:
Hash your client secrets. Store bcrypt hashes in the database and use bcrypt.compare() during authentication. Plaintext secrets in a database are a breach waiting to happen.
Use RS256 instead of HS256 for multi-service architectures. The default HS256 algorithm uses a shared secret: every service that needs to verify tokens must have the same secret. RS256 uses a private/public key pair: only the token server needs the private key, and verifiers only need the public key. This limits the blast radius if a service is compromised.
Add rate limiting to the authorize endpoint. Without it, an attacker can brute-force client credentials. A package like express-rate-limit handles this in a few lines.
Consider token revocation. JWTs are stateless by design: once issued, they're valid until they expire. If you need to revoke tokens before expiration (a compromised client, for example), you'll need a revocation list or a short expiration paired with refresh tokens.
Rotate your signing secret. If your JWT signing secret is compromised, every token ever issued with that secret can be forged. Rotate secrets periodically and keep them out of your codebase (use environment variables or a secrets manager).
Next Steps
The code above is a starting point. From here, the natural extensions are: adding refresh token support for long-lived sessions, building an admin interface for managing client credentials, implementing scope-based authorization for fine-grained access control, and adding request logging for audit trails.
The full source code is available at github.com/grizzlypeaksoftware/accesstokenserver.