DEV Community

Cover image for API Authentication Best Practices in 2026
APIVerve
APIVerve

Posted on • Originally published at blog.apiverve.com

API Authentication Best Practices in 2026

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' })
});
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode
// 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
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. Not validating state — CSRF attacks become trivial
  2. Storing tokens in localStorage — XSS can steal them
  3. Long-lived access tokens — If leaked, attacker has long-term access
  4. 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)
}
Enter fullscreen mode Exit fullscreen mode

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' });
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.

  2. The payload is NOT encrypted — It's base64-encoded. Anyone can decode it. Never put sensitive data (passwords, SSNs, secrets) in the payload.

  3. Size adds up — JWTs are bigger than session IDs. If you're passing them with every request, that's extra bandwidth.

  4. "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
});
Enter fullscreen mode Exit fullscreen mode

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' });
  }
});
Enter fullscreen mode Exit fullscreen mode

Key points:

  1. Rotate refresh tokens — Each use generates a new one, old one is invalidated
  2. Store refresh tokens in database — So you can revoke them
  3. 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);
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

Or just use Helmet.js and get them all:

const helmet = require('helmet');
app.use(helmet());
Enter fullscreen mode Exit fullscreen mode

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() });
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)