FFreeAPI Hub
Back to Blog
API Development

Understanding REST API Authentication: API Keys, OAuth 2.0, and JWT Compared

A deep dive into the three most common API authentication methods. Learn how each works, when to use them, and see working code examples in Python and JavaScript.

Jun 8, 202614 minFreeAPI Hub Team

Authentication is the first and most critical decision in any API integration. Get it wrong and you expose your application to security vulnerabilities, rate limit abuse, or broken user experiences. Yet many developers treat auth as an afterthought, copying boilerplate code without understanding what happens under the hood. This guide breaks down the three most common authentication methods, explains how each works, and shows you exactly when to use them.

API Keys: Simple but Limited

API keys are the simplest form of authentication. The API provider issues you a unique string, and you include it in every request. The server verifies the key and either allows or denies access. That is it. No tokens, no redirects, no refresh logic. This simplicity makes API keys the most common authentication method for public APIs.

The key typically goes in one of three places: a request header, a query parameter, or a custom header. Headers are the recommended approach because query parameters get logged in server access logs, creating a security risk. Here is how to send an API key in Python and JavaScript:

python
import requests

# Method 1: Authorization header (recommended)
headers = {"Authorization": "Bearer YOUR_API_KEY"}
response = requests.get("https://api.example.com/data", headers=headers)

# Method 2: Custom header (common pattern)
headers = {"X-API-Key": "YOUR_API_KEY"}
response = requests.get("https://api.example.com/data", headers=headers)

# Method 3: Query parameter (not recommended)
response = requests.get("https://api.example.com/data?api_key=YOUR_API_KEY")

print(response.json())
javascript
// JavaScript fetch with API key
const API_KEY = process.env.API_KEY;

// Recommended: Authorization header
const response = await fetch("https://api.example.com/data", {
  headers: {
    "Authorization": `Bearer ${API_KEY}`,
  },
});

const data = await response.json();
console.log(data);

API keys work well for server-to-server communication where the key never reaches the client. They are also fine for low-stakes public APIs. But they have significant limitations. A single key identifies your application, not individual users. You cannot grant different permissions to different users. And if the key leaks, anyone who has it can impersonate your application until you rotate it.

Security Note

Never expose API keys in client-side JavaScript. If your frontend needs to call an authenticated API, proxy the request through your backend.

OAuth 2.0: Delegation Without Sharing Passwords

OAuth 2.0 solves a different problem. Instead of identifying your application, it lets a user grant your application limited access to their account on another service, without sharing their password. This is how you log into a third-party app using your Google or GitHub account.

The OAuth 2.0 authorization code flow works in five steps. First, your application redirects the user to the provider authorization page. Second, the user logs in and approves your requested scopes. Third, the provider redirects back to your application with an authorization code. Fourth, your backend exchanges that code for an access token. Fifth, you use the access token to make API requests on behalf of the user.

javascript
// OAuth 2.0 Authorization Code Flow (Node.js)
const CLIENT_ID = process.env.OAUTH_CLIENT_ID;
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET;
const REDIRECT_URI = "http://localhost:3000/callback";

// Step 1: Redirect user to authorization page
function getAuthUrl() {
  const params = new URLSearchParams({
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    response_type: "code",
    scope: "read:user",
  });
  return `https://api.example.com/oauth/authorize?${params}`;
}

// Step 4: Exchange code for token (server-side)
async function exchangeCodeForToken(code) {
  const response = await fetch("https://api.example.com/oauth/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      redirect_uri: REDIRECT_URI,
      code: code,
    }),
  });
  return response.json();
  // Returns: { access_token, refresh_token, expires_in, token_type }
}

// Step 5: Use access token for API calls
async function getUserData(accessToken) {
  const response = await fetch("https://api.example.com/user", {
    headers: { "Authorization": `Bearer ${accessToken}` },
  });
  return response.json();
}

Access tokens expire, typically within an hour. When they do, you use the refresh token to get a new access token without requiring the user to log in again. This refresh flow is critical to implement correctly, otherwise users will be logged out repeatedly.

python
import requests

def refresh_access_token(refresh_token, client_id, client_secret):
    """Exchange refresh token for a new access token."""
    response = requests.post("https://api.example.com/oauth/token", data={
        "grant_type": "refresh_token",
        "refresh_token": refresh_token,
        "client_id": client_id,
        "client_secret": client_secret,
    })
    
    if response.status_code == 200:
        tokens = response.json()
        return tokens["access_token"], tokens.get("refresh_token")
    else:
        raise Exception(f"Token refresh failed: {response.status_code}")

# Usage
new_access, new_refresh = refresh_access_token(
    stored_refresh_token,
    CLIENT_ID,
    CLIENT_SECRET,
)

OAuth 2.0 is complex, but it is the right choice when your application acts on behalf of users. It provides granular scope-based permissions, user-specific rate limits, and the ability to revoke access per application. The complexity is justified by the security and flexibility gains.

JWT: Stateless and Self-Contained

JSON Web Tokens (JWT) take a fundamentally different approach. Instead of the server looking up the token in a database on every request, the token itself contains all the necessary information. The server just verifies the cryptographic signature. This makes JWTs stateless and fast, since there is no database lookup required for authentication.

A JWT has three parts separated by dots: header, payload, and signature. The header specifies the algorithm used for signing. The payload contains claims like user ID, role, and expiration time. The signature is computed using the header, payload, and a secret key known only to the server.

javascript
// Creating and verifying JWTs in Node.js
const jwt = require("jsonwebtoken");

const SECRET = process.env.JWT_SECRET;

// Create a token (typically done at login)
function createToken(userId, role) {
  return jwt.sign(
    { userId, role },
    SECRET,
    { expiresIn: "1h" }
  );
}

// Verify a token (done on each protected request)
function verifyToken(token) {
  try {
    const decoded = jwt.verify(token, SECRET);
    return { valid: true, payload: decoded };
  } catch (err) {
    return { valid: false, error: err.message };
  }
}

// Usage in an Express middleware
function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return res.status(401).json({ error: "Missing token" });
  }
  
  const token = authHeader.substring(7);
  const result = verifyToken(token);
  
  if (!result.valid) {
    return res.status(401).json({ error: "Invalid token" });
  }
  
  req.user = result.payload;
  next();
}

JWTs excel in microservice architectures. Service A can issue a token, and Service B can verify it without calling back to Service A. This eliminates a network round-trip on every request. JWTs also work well for single-page applications where the token is stored client-side and sent with each API call.

However, JWTs have a significant drawback: they cannot be easily revoked. Once issued, a JWT is valid until it expires. If a user logs out or their account is compromised, you cannot invalidate their existing tokens without maintaining a blocklist, which defeats the stateless advantage. For this reason, keep JWT expiration times short and use refresh tokens for session continuity.

Comparison: Which Should You Use?

The choice depends on your use case. Use API keys when you are calling an API from a backend service and do not need user-level identity. They are simple, fast to implement, and sufficient for most server-to-server integrations.

Use OAuth 2.0 when your application needs to access another service on behalf of a user. It is the standard for delegated access and provides the best security for user-facing applications. The complexity is real, but libraries like Passport.js and Authlib handle most of the heavy lifting.

Use JWT when you need stateless authentication within your own application or microservice cluster. JWTs are fast, self-contained, and work well at scale. Just remember to keep expiration times short and have a refresh strategy.

Many production systems combine these methods. For example, you might use OAuth 2.0 for user login, issue a JWT for session management, and use API keys for third-party service calls. Understanding each method individually is the first step to building a secure and maintainable authentication architecture.

api authenticationoauthjwtapi keys