I've watched developers spend weeks debating authentication strategies for APIs that get 100 requests a day. And I've seen startups launch with ?password=admin123 in their query strings. There's a middle ground, and it's not complicated.
Here's the thing: most authentication decisions are already made for you by your use case. Server talking to server? API keys. Users logging in? OAuth. Microservices verifying each other? JWTs. That's it. The rest is implementation details.
Let's get into those details.
Authentication vs Authorization (Yes, They're Different)
Quick clarification because these get confused constantly:
- Authentication = "Who are you?" (proving identity)
- Authorization = "What can you do?" (permissions)
You need both. Authenticating someone doesn't mean they can access everything. A valid API key doesn't mean that key has permission to delete your production database.
Most security holes happen when people conflate these. "Oh, they have a valid token, let them through!" No. Validate the token, then check what that token is allowed to do.
The Three Methods That Actually Matter
There are dozens of auth schemes out there. You need to know three.
API Keys: The Workhorse
API keys are just unique strings that identify who's making a request. Nothing fancy. No cryptographic magic. Just a long, random string that you check against a database.
// How APIVerve handles it - header-based
const response = await fetch('https://api.apiverve.com/v1/emailvalidator', {
method: 'POST',
headers: {
'x-api-key': 'YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({ email: 'test@example.com' })
});
Why headers instead of query params? Because query params end up in:
- Server logs
- Browser history
- Referer headers
- Analytics tools
- That screenshot your coworker took
Headers stay out of sight. Use them.
When to use API keys:
- Server-to-server communication
- Backend services calling APIs
- Anything where a human isn't directly involved
- Internal microservices (with rotation)
When NOT to use API keys:
- Client-side JavaScript (anyone can see them)
- Mobile apps without a backend proxy
- Anywhere the key could be extracted
The Password Generator API is great for generating secure API keys if you're building your own auth system. 32+ characters, mix of everything, no dictionary words.
OAuth 2.0: When Users Are Involved
OAuth exists because you don't want users giving their actual passwords to third-party apps. Instead, users authorize access through your system, and you give the app a limited token.
The flow that matters for most developers is the Authorization Code Flow:
1. User clicks "Login with Google"
2. Redirect to Google's auth page
3. User approves access
4. Google redirects back with a code
5. Your backend exchanges code for tokens
6. You get access_token (short-lived) + refresh_token (long-lived)
The actual code looks like this:
// Step 1: Redirect user to auth provider
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
authUrl.searchParams.set('client_id', process.env.GOOGLE_CLIENT_ID);
authUrl.searchParams.set('redirect_uri', 'https://yourapp.com/callback');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'email profile');
authUrl.searchParams.set('state', generateRandomState()); // CSRF protection
// Redirect user to authUrl.toString()
// Step 2: Handle the callback
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state matches what you sent (prevents CSRF)
if (state !== getStoredState(req)) {
return res.status(403).send('Invalid state');
}
// Exchange code for tokens
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
redirect_uri: 'https://yourapp.com/callback',
grant_type: 'authorization_code'
})
});
const { access_token, refresh_token } = await tokenResponse.json();
// Store tokens, create session, redirect user
});
The state parameter is critical. Without it, attackers can craft malicious URLs that complete the OAuth flow with their tokens, hijacking your users' sessions. I've seen this vulnerability in production apps from companies that should know better.
Common OAuth mistakes:
- Not validating state — CSRF attacks become trivial
- Storing tokens in localStorage — XSS can steal them
- Long-lived access tokens — If leaked, attacker has long-term access
- Not implementing token refresh — Users get logged out constantly
JWTs: Stateless Authentication
JWTs (JSON Web Tokens) are self-contained tokens. They carry their own payload (user ID, permissions, expiration) and a signature that proves they haven't been tampered with.
Structure: header.payload.signature
// A decoded JWT payload looks like this:
{
"sub": "user_12345", // Subject (user ID)
"email": "dev@example.com",
"role": "admin",
"iat": 1704067200, // Issued at
"exp": 1704070800 // Expires at (1 hour later)
}
The server signs this with a secret key. When a request comes in with a JWT, you verify the signature and check expiration. No database lookup needed.
// Verifying a JWT (using jsonwebtoken library)
const jwt = require('jsonwebtoken');
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
// Token invalid or expired
return res.status(403).json({ error: 'Invalid token' });
}
}
Need to inspect a JWT you received? The JWT Decoder API will break it down for you — handy for debugging without setting up local tooling.
JWT gotchas:
JWTs can't be revoked — Once issued, they're valid until expiration. If a user logs out or you need to invalidate a session, you need a token blacklist (which defeats the "stateless" benefit). Keep tokens short-lived.
The payload is NOT encrypted — It's base64-encoded. Anyone can decode it. Never put sensitive data (passwords, SSNs, secrets) in the payload.
Size adds up — JWTs are bigger than session IDs. If you're passing them with every request, that's extra bandwidth.
"none" algorithm attacks — Old JWT libraries accepted
"alg": "none"which means "no signature needed." Make sure your library explicitly requires a specific algorithm.
Where Should Tokens Live?
This decision matters more than people think.
| Storage | XSS Safe? | CSRF Safe? | Persistent? | Notes |
|---|---|---|---|---|
| httpOnly Cookie | Yes | No* | Yes | Best for most web apps |
| localStorage | No | Yes | Yes | Avoid for sensitive tokens |
| sessionStorage | No | Yes | No | Cleared on tab close |
| Memory (JS variable) | Yes | Yes | No | Lost on refresh |
*httpOnly cookies need CSRF protection (tokens or SameSite attribute)
My recommendation: httpOnly cookies with SameSite=Strict for web apps. The browser handles sending them automatically, JavaScript can't access them (XSS protection), and SameSite prevents most CSRF attacks.
// Setting a secure cookie
res.cookie('auth_token', token, {
httpOnly: true, // JavaScript can't read it
secure: true, // HTTPS only
sameSite: 'strict', // Same-site requests only
maxAge: 900000 // 15 minutes
});
For SPAs that need to call APIs on different domains, you might need localStorage. In that case, keep tokens short-lived and implement proper refresh token rotation.
Refresh Token Flow Done Right
Access tokens should be short-lived (15 minutes to 1 hour). Refresh tokens can last longer (days to weeks) but need extra protection.
// Token refresh endpoint
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.cookies; // or req.body
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
// Verify the refresh token
const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
// Check if it's been revoked (database lookup)
const storedToken = await db.refreshTokens.findOne({
token: refreshToken,
userId: decoded.sub,
revoked: false
});
if (!storedToken) {
return res.status(401).json({ error: 'Token revoked' });
}
// Rotate: invalidate old refresh token, issue new one
await db.refreshTokens.updateOne(
{ token: refreshToken },
{ revoked: true, revokedAt: new Date() }
);
const newRefreshToken = generateRefreshToken(decoded.sub);
await db.refreshTokens.insertOne({
token: newRefreshToken,
userId: decoded.sub,
createdAt: new Date()
});
// Issue new access token
const accessToken = jwt.sign(
{ sub: decoded.sub },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.json({ accessToken });
} catch (err) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});
Key points:
- Rotate refresh tokens — Each use generates a new one, old one is invalidated
- Store refresh tokens in database — So you can revoke them
- Detect reuse — If someone tries to use an already-rotated token, invalidate ALL tokens for that user (possible theft)
Rate Limiting: Your First Line of Defense
Authentication means nothing if attackers can brute-force your login endpoint. Rate limiting stops that.
const rateLimit = require('express-rate-limit');
// General API rate limit
const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute
message: { error: 'Too many requests, slow down' }
});
// Stricter limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: { error: 'Too many login attempts, try again later' }
});
app.use('/api/', apiLimiter);
app.use('/auth/login', authLimiter);
app.use('/auth/register', authLimiter);
This is the bare minimum. For production, consider:
- Sliding windows instead of fixed (smoother limiting)
- IP + User ID based limits (prevent distributed attacks on single account)
- Exponential backoff (1 min, 5 min, 15 min, 1 hour lockout)
- CAPTCHA after N failed attempts
Security Headers You Need
These take 5 minutes to add and prevent entire classes of attacks:
app.use((req, res, next) => {
// Prevent MIME type sniffing
res.setHeader('X-Content-Type-Options', 'nosniff');
// Block clickjacking
res.setHeader('X-Frame-Options', 'DENY');
// Force HTTPS for a year
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
// Limit where scripts/styles can load from
res.setHeader('Content-Security-Policy', "default-src 'self'");
next();
});
Or just use Helmet.js and get them all:
const helmet = require('helmet');
app.use(helmet());
Common Authentication Mistakes (I've Made Most of These)
1. Logging Sensitive Data
// DON'T DO THIS
console.log('User login:', { email, password });
console.log('Request headers:', req.headers); // Includes auth tokens
// Better
console.log('User login attempt:', { email, timestamp: Date.now() });
Those logs end up in CloudWatch, Datadog, or some monitoring tool. Now your secrets are in three places instead of one.
2. Timing Attacks on Password Comparison
// WRONG - returns early on first mismatch
if (password !== storedPassword) return false;
// RIGHT - constant time comparison
const crypto = require('crypto');
const isValid = crypto.timingSafeEqual(
Buffer.from(password),
Buffer.from(storedPassword)
);
If the wrong comparison returns faster when fewer characters match, attackers can guess passwords character by character.
3. Insufficient Entropy in Secrets
// WRONG
const apiKey = Math.random().toString(36); // ~62 bits of entropy
// BETTER
const crypto = require('crypto');
const apiKey = crypto.randomBytes(32).toString('hex'); // 256 bits
Or just use the Hash Generator API to create secure tokens. No need to worry about your random number generator being weak.
4. Hardcoded Secrets
// WRONG (I've seen this in production)
const JWT_SECRET = 'my-super-secret-key-123';
// RIGHT
const JWT_SECRET = process.env.JWT_SECRET;
// And rotate it periodically
Secrets in code end up in Git history forever. Even if you delete them, they're in old commits.
5. No Password Requirements
At minimum:
- 8 characters (12+ is better)
- Check against breach databases (HaveIBeenPwned API)
- Don't enforce arbitrary complexity rules (they make passwords worse, not better)
async function validatePassword(password) {
if (password.length < 12) {
return { valid: false, error: 'Password must be at least 12 characters' };
}
// Check against known breaches (simplified)
const hash = crypto.createHash('sha1').update(password).digest('hex');
const prefix = hash.substring(0, 5);
const suffix = hash.substring(5).toUpperCase();
const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
const data = await response.text();
if (data.includes(suffix)) {
return { valid: false, error: 'Password found in data breach, choose another' };
}
return { valid: true };
}
What Should You Actually Use?
Here's my decision tree:
Are users involved?
├── No → API Keys
│ └── Backend service calling APIVerve APIs? Use your dashboard key.
│ └── Internal microservices? API keys with regular rotation.
│
└── Yes → OAuth 2.0 + JWTs
└── Web app? Authorization Code Flow + httpOnly cookies
└── Mobile app? Authorization Code Flow with PKCE
└── SPA calling same-domain API? httpOnly cookies
└── SPA calling different-domain API? Short-lived JWTs in memory
For most developers integrating APIs like those on APIVerve's marketplace, you're in the "backend service" bucket. Get your API key from the dashboard, stick it in an environment variable, and you're done.
If you're building a user-facing product and need to implement auth from scratch, consider using established providers (Auth0, Clerk, Supabase Auth) rather than rolling your own. They've thought through edge cases you haven't encountered yet.
Next Steps
Need to generate secure credentials? The Password Generator API creates cryptographically secure strings. Building JWT validation? The JWT Decoder API helps you debug tokens without base64 decoding by hand.
Ready to build something? Grab a free API key and start integrating. 50 free credits, no card required.
Got questions about authentication patterns? Hit us up on Twitter @APIVerve.
Originally published at APIVerve Blog
Top comments (0)