Authentication in Full Stack Apps: How JWT and OAuth Work With LLM APIs
A startup launched an AI writing assistant. The backend was solid — Node.js, PostgreSQL, OpenAI integration. Within 48 hours of launch, one user had burned through $340 of the company's API credit.
The root cause was not a security breach. There was no hacker. The problem was architectural: the application had no per-user authentication tied to the LLM API calls. Every request to the AI endpoint was treated identically regardless of who sent it. One user with a bot sending automated requests consumed resources with no per-user limit, no attribution, and no circuit breaker.
This is the authentication problem specific to LLM-integrated applications. In a traditional app, authentication protects data access. In an LLM app, authentication protects something that costs real money on every call — and uncontrolled access is a financial risk, not just a data risk.

Why Authentication in LLM Apps Is Architecturally Different
In a traditional full stack app, authentication gates access to database records. An unauthenticated user cannot read your users table. The cost of an unauthenticated request is one database query that returns a 401.
In an LLM app, the cost structure is fundamentally different:
- Every API call to OpenAI, Anthropic, or Google Gemini costs money
- The cost scales with the length of the conversation — more history = more tokens = higher cost
- There is no built-in rate limiting at the model provider level for your account
- A single automated script can consume thousands of dollars of credit before you notice
Authentication in this context must do two things traditional auth does not: attribute every API call to a specific authenticated user, and enforce per-user limits that protect the economics of your application.
The architectural implication: authentication cannot be an afterthought bolted on after the LLM integration is working. It must be the layer through which every LLM call passes.
JWT: How It Actually Works and What Most Developers Get Wrong
JSON Web Tokens are the most common authentication mechanism for Node.js full stack applications, and they are also the most commonly misimplemented.
The correct JWT structure:
A JWT has three base64-encoded parts: header (algorithm), payload (claims including user ID, expiry, roles), and signature. The server signs the token with a secret key. When the client sends the token back, the server verifies the signature and trusts the payload without a database lookup.
What most developers get wrong:
Wrong 1: Storing JWT in localStorage
// BAD — never do this
localStorage.setItem('token', jwtToken);
localStorage is accessible to any JavaScript running on your page. An XSS vulnerability allows an attacker to steal the JWT from localStorage and use it to make authenticated requests. This is a well-documented, common attack vector.
// CORRECT — httpOnly cookie
res.cookie('token', jwtToken, {
httpOnly: true, // Cannot be read by JavaScript — only sent by browser automatically
secure: true, // Only sent over HTTPS
sameSite: 'strict', // CSRF protection
maxAge: 15 * 60 * 1000, // 15 minutes
});
httpOnly cookies cannot be read by JavaScript — only sent by the browser automatically with same-origin requests. An XSS attack cannot steal what JavaScript cannot read.
Wrong 2: No expiry or very long expiry
A JWT that never expires is a permanent authentication credential. If it is stolen, the attacker has permanent access.
// Access token: short-lived
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' }
);
// Refresh token: longer-lived, stored in database for revocation
const refreshToken = jwt.sign(
{ userId: user.id, tokenVersion: user.tokenVersion },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Store refresh token hash in database
await db.query(
'UPDATE users SET refresh_token_hash = $1 WHERE id = $2',
[hash(refreshToken), user.id]
);
The access token is used for API calls. The refresh token generates new access tokens without requiring the user to log in again. Storing the refresh token hash in the database enables revocation — log out by deleting the database record.
JWT Middleware for LLM Routes
Every LLM endpoint must verify authentication before processing the request:
import jwt from 'jsonwebtoken';
export function authenticateLLMRequest(req, res, next) {
// Get token from httpOnly cookie
const token = req.cookies?.accessToken;
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
// Attach user info to request — available in all subsequent middleware and route handlers
req.user = {
id: payload.userId,
email: payload.email,
role: payload.role,
};
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
// Apply to ALL LLM routes
app.use('/api/chat', authenticateLLMRequest, chatRouter);
app.use('/api/generate', authenticateLLMRequest, generateRouter);
Per-User Token Budget Enforcement
Authentication gives you user identity. The next step is using that identity to enforce cost limits:
const USAGE_LIMITS = {
free: {
tokensPerDay: 5000,
requestsPerDay: 20,
requestsPerMinute: 3,
},
pro: {
tokensPerDay: 50000,
requestsPerDay: 200,
requestsPerMinute: 10,
},
enterprise: {
tokensPerDay: 500000,
requestsPerDay: 2000,
requestsPerMinute: 50,
},
};
export async function checkAndTrackUsage(userId, userPlan, estimatedTokens) {
const limits = USAGE_LIMITS[userPlan] || USAGE_LIMITS.free;
const today = new Date().toISOString().slice(0, 10);
const dailyKey = `usage:${userId}:${today}`;
const minuteKey = `rate:${userId}:${Math.floor(Date.now() / 60000)}`;
const [dailyUsage, minuteCount] = await Promise.all([
redis.hgetall(dailyKey),
redis.incr(minuteKey),
]);
if (minuteCount === 1) await redis.expire(minuteKey, 60);
if (minuteCount > limits.requestsPerMinute) {
throw new RateLimitError('Too many requests — please wait a moment');
}
const dailyRequests = parseInt(dailyUsage?.requests || '0');
const dailyTokens = parseInt(dailyUsage?.tokens || '0');
if (dailyRequests >= limits.requestsPerDay) {
throw new UsageLimitError('Daily request limit reached');
}
if (dailyTokens + estimatedTokens > limits.tokensPerDay) {
throw new UsageLimitError('Daily token limit reached');
}
// Track usage after successful check
await redis.hincrby(dailyKey, 'requests', 1);
await redis.expire(dailyKey, 86400);
return {
remainingRequests: limits.requestsPerDay - dailyRequests - 1,
remainingTokens: limits.tokensPerDay - dailyTokens,
};
}
OAuth: When to Use It and How It Connects to LLM Auth
OAuth 2.0 (via "Sign in with Google", "Sign in with GitHub", etc.) is useful for LLM applications when:
- You want to reduce friction by letting users authenticate with existing accounts
- You want to access user data from a third-party service on their behalf (their Google Drive files, GitHub repositories) to use as LLM context
OAuth flow with JWT for LLM apps:
// After OAuth callback — user authenticated via Google/GitHub
app.get('/auth/callback', async (req, res) => {
const { code } = req.query;
// Exchange code for access token with OAuth provider
const oauthTokens = await exchangeCodeForTokens(code);
// Get user profile from OAuth provider
const profile = await getOAuthProfile(oauthTokens.access_token);
// Find or create user in your database
const user = await findOrCreateUser({
email: profile.email,
name: profile.name,
oauthProvider: 'google',
oauthId: profile.id,
});
// Issue YOUR own JWT — do not use the OAuth provider's token for your LLM calls
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, plan: user.subscriptionPlan },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' }
);
res.cookie('accessToken', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
res.redirect('/dashboard');
});
Important: After OAuth authentication, issue your own JWT. Don't use the OAuth provider's access token for your LLM API calls — that token is for calling the OAuth provider's APIs, not your own. Your JWT is what gates LLM access, carries your user ID for cost attribution, and embeds your application's role and plan information.

Attributing LLM Costs to Users
With authentication in place, every LLM call can be attributed to a specific user. Log token usage on every request:
async function callLLMAndTrackCost(req, messages) {
const start = Date.now();
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages,
max_tokens: 1000,
});
const { prompt_tokens, completion_tokens, total_tokens } = response.usage;
// Log to database for billing and analytics
await db.query(`
INSERT INTO llm_usage (
user_id, tokens_in, tokens_out, total_tokens,
model, latency_ms, feature, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
`, [
req.user.id,
prompt_tokens,
completion_tokens,
total_tokens,
'gpt-4o',
Date.now() - start,
req.path,
]);
// Update daily usage in Redis
await redis.hincrbyfloat(
`usage:${req.user.id}:${new Date().toISOString().slice(0, 10)}`,
'tokens',
total_tokens
);
return response;
}
This attribution enables monthly usage reports, per-feature cost breakdowns, identifying users approaching limits before they hit them, and detecting anomalous usage patterns early — before a user generates a four-figure API bill.
The Complete Auth Checklist for LLM Apps
Before shipping any authenticated LLM feature:
- JWT stored in
httpOnlycookies, not localStorage - Short-lived access tokens (15 minutes) with refresh token rotation
- Refresh tokens stored as hashed values in database (enables revocation)
- Every LLM endpoint protected by JWT middleware — no unauthenticated access
- Per-user rate limiting by minute and by day
- Token usage logged per authenticated request with user attribution
- Per-plan token budgets enforced before calling the LLM API
- OAuth callback issues your own JWT — not the OAuth provider's token
Authentication in LLM apps is not optional. It is the financial control layer that separates a sustainable product from one that generates $340 surprise bills within 48 hours of launch.





