Introduction
The /api/oauth/token endpoint in server/routes/oauth.js handles one of the most security-critical operations in any application: exchanging credentials for access tokens. However, a dangerous oversight at line 140 left this endpoint vulnerable to a resource exhaustion attack that could bring down the entire server.
The problem wasn't with the authentication logic itself—input sanitization was already in place. The issue was that every single request to this endpoint triggers an expensive bcrypt verification operation, and there was absolutely nothing stopping an attacker from sending thousands of requests per second.
// Before the fix - no rate limiting on this expensive endpoint
app.post(buildServerPath('/api/oauth/token'), async (req, res) => {
// Each request triggers bcrypt.compare() - extremely CPU intensive
For developers building OAuth implementations or any authentication system, this vulnerability demonstrates why defense-in-depth matters: even well-validated inputs can become attack vectors when resource consumption isn't controlled.
The Vulnerability Explained
Why bcrypt Makes This Dangerous
The OAuth token endpoint uses bcrypt to verify client secrets. Bcrypt is intentionally slow—that's its security feature. Each verification operation is designed to take significant CPU time to prevent offline brute-force attacks. But this same property becomes a weapon when attackers can trigger unlimited verifications.
Here's what the vulnerable code path looked like:
app.post(buildServerPath('/api/oauth/token'), async (req, res) => {
try {
const platform = configCache.getPlatform() || {};
const oauthConfig = platform.oauth || {};
// ... bcrypt verification happens here for every request
Attack Scenario
An attacker targeting this specific endpoint could execute a straightforward denial-of-service attack:
- Identify the endpoint: The
/api/oauth/tokenpath is publicly accessible and follows standard OAuth conventions - Craft minimal requests: Send POST requests with any
grant_type,username, andpasswordvalues - Flood the server: Launch parallel requests from multiple IPs or a single source
- Exhaust resources: Each request forces the server to execute bcrypt operations, consuming CPU cycles
With just 50 concurrent requests (as tested in the regression suite), the server would be spending all its CPU time on bcrypt operations, leaving legitimate users unable to authenticate or access the application.
Real-World Impact
For this Node.js application, the impact is severe:
- Complete service disruption: The single-threaded event loop becomes blocked by CPU-intensive bcrypt operations
- No authentication possible: Legitimate users cannot obtain tokens
- Cascading failures: Other routes sharing the same process become unresponsive
- Low attack cost: The attacker needs minimal resources compared to the damage inflicted
The Fix
The fix introduces express-rate-limit middleware specifically configured for the OAuth token endpoint:
Before (Vulnerable)
app.post(buildServerPath('/api/oauth/token'), async (req, res) => {
After (Protected)
import rateLimit from 'express-rate-limit';
// Mitigates brute-force / resource-exhaustion attacks: each request to the
// token endpoint triggers an expensive bcrypt client-secret comparison, so an
// unauthenticated flood can exhaust CPU. Limit per-IP independently of any
// broader shared limiter.
const oauthTokenLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minute window
limit: 20, // 20 requests per window
standardHeaders: true, // Return rate limit info in headers
legacyHeaders: false, // Disable X-RateLimit-* headers
message: { error: 'Too many token requests from this IP, please try again later.' }
});
app.post(buildServerPath('/api/oauth/token'), oauthTokenLimiter, async (req, res) => {
Why These Specific Settings?
The configuration choices are deliberate:
| Setting | Value | Rationale |
|---|---|---|
windowMs |
15 minutes | Balances security with legitimate retry scenarios |
limit |
20 requests | Allows normal OAuth flows while blocking floods |
standardHeaders |
true | Enables RateLimit-* headers per RFC draft |
legacyHeaders |
false | Avoids deprecated header format |
The middleware is applied only to the token endpoint, not globally. This ensures that:
- Other routes aren't unnecessarily restricted
- The expensive bcrypt operations are specifically protected
- Rate limit state is isolated to this security-critical path
Key Security Improvement
When the rate limit is exceeded, the server immediately returns HTTP 429 (Too Many Requests) without executing the bcrypt operation. This is crucial—the expensive work is avoided entirely for excess requests, preserving CPU for legitimate traffic.
Prevention & Best Practices
1. Identify Expensive Operations
Audit your codebase for computationally expensive operations exposed to unauthenticated users:
// Red flags to look for:
bcrypt.compare() // Password/secret verification
bcrypt.hash() // Hashing operations
crypto.pbkdf2() // Key derivation
sharp().resize() // Image processing
pdf.create() // Document generation
2. Layer Your Rate Limiting
Implement rate limiting at multiple levels:
// Global limiter for all routes
const globalLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 100
});
// Stricter limiter for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 20
});
app.use(globalLimiter);
app.use('/api/auth', authLimiter);
app.use('/api/oauth', authLimiter);
3. Consider Additional Protections
- Request queuing: Limit concurrent bcrypt operations
- CAPTCHA: Add challenges after failed attempts
- IP reputation: Block known malicious sources
- Monitoring: Alert on unusual request patterns
4. Test Your Limits
The regression test from this PR demonstrates how to verify rate limiting:
test.each(testCases)('handles $name without resource exhaustion', async ({ name, count }) => {
const requests = Array(count).fill(null).map(() =>
request(app)
.post('/api/oauth/token')
.send({ grant_type: 'password', username: 'test', password: 'test' })
);
const responses = await Promise.all(requests);
const rateLimited = responses.some(r => r.status === 429);
expect(count === 1 || rateLimited).toBe(true);
});
Key Takeaways
- The
/api/oauth/tokenendpoint's bcrypt operations made it a prime DoS target — any publicly accessible endpoint with expensive operations needs rate limiting - Rate limiting must be applied before the expensive operation — returning 429 early prevents resource consumption, not just abuse detection
- Per-endpoint rate limiters provide granular control — the
oauthTokenLimiteris independent of any global limiter, ensuring appropriate limits for this specific threat - Input validation is not resource protection — this endpoint had proper input sanitization but still needed rate limiting to prevent abuse
- The 20 requests per 15 minutes limit balances security and usability — legitimate OAuth flows rarely need more, while attacks require thousands
How Orbis AppSec Detected This
- Source: Unauthenticated HTTP POST requests to
/api/oauth/token - Sink:
bcrypt.compare()operation triggered for every request inserver/routes/oauth.js:140 - Missing control: No rate limiting middleware protecting the expensive authentication operation
- CWE: CWE-770 (Allocation of Resources Without Limits or Throttling)
- Fix: Added
express-rate-limitmiddleware configured for 20 requests per 15-minute window per IP address
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
This vulnerability in server/routes/oauth.js illustrates a critical principle: security isn't just about validating inputs—it's about controlling resource consumption. The bcrypt algorithm's intentional slowness, designed to protect against offline attacks, became a liability when exposed to unlimited online requests.
The fix is elegantly simple: a rate limiter that stops abuse before the expensive operation executes. For any developer building authentication systems, this pattern should be standard practice. Identify your expensive operations, protect them with appropriate rate limits, and test that those limits actually work under load.