<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ api - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ api - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Tue, 06 Oct 2026 12:34:39 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/api/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ API Authentication & Authorization: An Engineering Deep Dive into Mechanisms, Trade-offs, and Failure Modes ]]>
                </title>
                <description>
                    <![CDATA[ Every API has some form of authentication. But having authentication and getting it right are two completely different things. I've reviewed production systems where JWTs had no expiry. Systems where  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/api-authentication-authorization-mechanisms-trade-offs-and-failure-modes/</link>
                <guid isPermaLink="false">6ac3b79f3a1cb65fb3944008</guid>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ authorization ]]>
                    </category>
                
                    <category>
                        <![CDATA[ authentication ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ backend developments ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Mon, 05 Oct 2026 14:43:43 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/51178fc1-fae2-4f9e-b52c-3f2878db07d0.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every API has some form of authentication. But having authentication and getting it right are two completely different things.</p>
<p>I've reviewed production systems where JWTs had no expiry. Systems where API keys were hardcoded in source code and committed to public repositories. Systems where OAuth redirect URIs used wildcards. Systems where Basic Auth was being used for financial APIs over what was supposed to be HTTPS but nobody checked.</p>
<p>Every single one of those was a live vulnerability waiting to be exploited.</p>
<p>Most of these problems didn't come from careless engineers. They came from engineers who understood how to make the mechanism work but didn't understand the failure modes. Nobody told them what happens when it breaks. Nobody defined what the organizational standard was. They picked what they knew, implemented it well enough to pass code review, and moved on.</p>
<p>This article is about changing that. Not just how each mechanism works, but when to use it, when not to use it, and exactly how it fails in production.</p>
<p>Before anything else, let's clear up a confusion that causes real vulnerabilities. Authentication answers: who are you? Authorization answers: what are you allowed to do?</p>
<p>A system that authenticates perfectly but authorizes poorly will still serve unauthorized data. A system that authorizes perfectly but authenticates weakly is trivially bypassed. Both must be correct, independently.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-foundation-what-you-must-get-right-before-choosing-a-mechanism">The Foundation: What You Must Get Right Before Choosing a Mechanism</a></p>
</li>
<li><p><a href="#heading-1-basic-authentication">1. Basic Authentication</a></p>
</li>
<li><p><a href="#heading-2-api-keys">2. API Keys</a></p>
</li>
<li><p><a href="#heading-3-bearer-token-authentication">3. Bearer Token Authentication</a></p>
</li>
<li><p><a href="#heading-4-jwt-json-web-token">4. JWT - JSON Web Token</a></p>
</li>
<li><p><a href="#heading-5-oauth-20">5. OAuth 2.0</a></p>
</li>
<li><p><a href="#heading-6-openid-connect-oidc">6. OpenID Connect (OIDC)</a></p>
</li>
<li><p><a href="#heading-7-mutual-tls-mtls">7. Mutual TLS (mTLS)</a></p>
</li>
<li><p><a href="#heading-choosing-the-right-mechanism">Choosing the Right Mechanism</a></p>
</li>
<li><p><a href="#heading-the-organizational-discipline-that-ties-everything-together">The Organizational Discipline That Ties Everything Together</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before reading this article, you should be comfortable with:</p>
<ul>
<li><p>What an API is and how HTTP requests and responses work</p>
</li>
<li><p>A basic understanding of what a token or session is</p>
</li>
<li><p>General software architecture concepts: what a gateway is, and what a service layer is</p>
</li>
<li><p>Familiarity with Dart or C# syntax</p>
</li>
</ul>
<p>You don't need a security background. Every concept here is explained from an engineering perspective.</p>
<h2 id="heading-the-foundation-what-you-must-get-right-before-choosing-a-mechanism">The Foundation: What You Must Get Right Before Choosing a Mechanism</h2>
<p>Before you even think about which mechanism to use, three things must be in place. No mechanism saves you if these are missing.</p>
<h3 id="heading-1-tls-isnt-optional">1. TLS isn't Optional</h3>
<p>Every API communicates over HTTPS. Every endpoint and environment, not just production. Not just the endpoints that handle card numbers. All of them.</p>
<p>Without TLS, every mechanism in this article can be intercepted. Basic Auth tokens, API keys, bearer tokens, JWT all travel in HTTP headers. HTTP headers are plaintext without TLS.</p>
<p>Your infrastructure must enforce TLS 1.2 minimum. TLS 1.0 and 1.1 have known vulnerabilities. SSLv3 is catastrophically broken. If a client tries to negotiate an older protocol version, the connection must be rejected at the infrastructure layer. This is a configuration decision, not something you handle in code.</p>
<h3 id="heading-2-production-apis-must-not-be-accessible-on-public-tooling-without-proper-controls">2. Production APIs Must Not Be Accessible on Public Tooling Without Proper Controls</h3>
<p>A production API accessible over the internet through Postman or Swagger without proper access controls is a problem waiting to happen.</p>
<p>Development and testing must use dedicated environments with separate credentials that have zero access to production data.</p>
<h3 id="heading-3-production-data-must-not-be-copied-to-development-or-test-environments">3. Production Data Must Not Be Copied to Development or Test Environments</h3>
<p>This isn't just good practice. Under Nigeria's NDPA 2023, personal data must be processed only for specified, explicit, and legitimate purposes. Copying production personal data to a development environment creates direct legal exposure. Under PCI-DSS, any environment that stores, processes, or transmits cardholder data is in scope for compliance.</p>
<p>The engineering answer is synthetic test data and anonymized datasets in every non-production environment, always.</p>
<p>With that in place, here are the seven mechanisms.</p>
<h2 id="heading-1-basic-authentication">1. Basic Authentication</h2>
<h3 id="heading-how-it-works">How It Works</h3>
<p>The client sends a username and password on every single request. The credentials are combined as <code>username:password</code>, encoded in Base64, and placed in the Authorization header.</p>
<pre><code class="language-plaintext">Authorization: Basic am9objpzZWNyZXQxMjM=
</code></pre>
<p>That encoded string is <code>john:secret123</code> in Base64. Base64 isn't encryption. It's encoding. Anyone who captures that header can decode it in seconds using any Base64 decoder available online.</p>
<p><strong>Dart:</strong></p>
<pre><code class="language-dart">// server-side basic auth validation
String? extractBasicAuthCredentials(Request request) {
  final authHeader = request.headers['authorization'];
  if (authHeader == null || !authHeader.startsWith('Basic ')) return null;

  final encoded = authHeader.substring(6);
  final decoded = utf8.decode(base64.decode(encoded));
  return decoded; 
}

Handler basicAuthMiddleware(Handler handler, UserService userService) {
  return (Request request) async {
    final credentials = extractBasicAuthCredentials(request);
    if (credentials == null) {
      return Response.unauthorized(
        'Missing credentials',
        headers: {'WWW-Authenticate': 'Basic realm="API"'},
      );
    }

    final parts = credentials.split(':');
    if (parts.length != 2) return Response.unauthorized('Invalid credentials');

    final isValid = await userService.validateCredentials(parts[0], parts[1]);
    if (!isValid) return Response.unauthorized('Invalid credentials');

    return handler(request);
  };
}
</code></pre>
<p><strong>C#:</strong></p>
<pre><code class="language-csharp">public class BasicAuthHandler : AuthenticationHandler&lt;AuthenticationSchemeOptions&gt;
{
    private readonly IUserService _userService;

    protected override async Task&lt;AuthenticateResult&gt; HandleAuthenticateAsync()
    {
        if (!Request.Headers.ContainsKey("Authorization"))
            return AuthenticateResult.Fail("Missing Authorization header");

        var authHeader = Request.Headers["Authorization"].ToString();
        if (!authHeader.StartsWith("Basic "))
            return AuthenticateResult.Fail("Invalid Authorization scheme");

        var encoded = authHeader.Substring(6);
        var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
        var parts = decoded.Split(':');

        if (parts.Length != 2)
            return AuthenticateResult.Fail("Invalid credentials format");

        var isValid = await _userService.ValidateCredentials(parts[0], parts[1]);
        if (!isValid)
            return AuthenticateResult.Fail("Invalid credentials");

        var claims = new[] { new Claim(ClaimTypes.Name, parts[0]) };
        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);

        return AuthenticateResult.Success(ticket);
    }
}
</code></pre>
<h3 id="heading-the-real-problem-with-basic-auth">The Real Problem With Basic Auth</h3>
<p>Credentials travel on every single request. One intercepted request gives an attacker the username and password permanently. There's no expiry. There's no revocation short of changing the password. Base64 provides zero security.</p>
<p>No rate limiting on a Basic Auth endpoint is an open invitation for brute force. An attacker with a list of common passwords will systematically try every one. If nothing stops them, they'll eventually get in.</p>
<h3 id="heading-when-to-use-it">When to Use It</h3>
<p>Basic auth works for internal server-to-server communication within a tightly controlled environment where the connection is always encrypted and the API surface is not exposed to the internet. Never use it for user-facing APIs, for anything handling sensitive data, or without TLS.</p>
<h2 id="heading-2-api-keys">2. API Keys</h2>
<p>An API key is simply a unique secret string that a server issues to a client to identify it. When you sign up to use a third-party service like a payment gateway, or an SMS platform, you're issued a key. Every request your application makes to that service includes that key so the service knows it's you calling, can track your usage, and can apply the right permissions and rate limits to your requests.</p>
<p>It's not tied to a user. It's tied to your application. That's the fundamental difference between an API key and a user authentication token</p>
<h3 id="heading-how-it-works">How It Works</h3>
<p>A static secret string is issued to a client and sent in a header on every request.</p>
<pre><code class="language-plaintext">X-API-Key: sk_live_abc123xyz
</code></pre>
<p><strong>Dart:</strong></p>
<pre><code class="language-dart">class ApiKeyService {
  final ApiKeyRepository _repository;

  ApiKeyService(this._repository);

  Future&lt;Result&lt;ApiKeyContext, AppException&gt;&gt; validateApiKey(
    String apiKey,
    String callerDomain,
  ) async {
    final keyRecord = await _repository.findByKey(apiKey);

    if (keyRecord == null) {
      return Result.failure(AppException.unauthorized('Invalid API key'));
    }

    if (keyRecord.isExpired) {
      return Result.failure(AppException.unauthorized('API key has expired'));
    }

    if (keyRecord.isRevoked) {
      return Result.failure(AppException.unauthorized('API key has been revoked'));
    }

    // validate that the calling domain is allowed for this key
    if (!keyRecord.allowedDomains.contains(callerDomain)) {
      return Result.failure(
        AppException.forbidden('Calling domain not authorized for this API key'),
      );
    }

    return Result.success(ApiKeyContext(
      clientId: keyRecord.clientId,
      environment: keyRecord.environment,
      allowedScopes: keyRecord.allowedScopes,
    ));
  }
}

Handler apiKeyMiddleware(Handler handler, ApiKeyService apiKeyService) {
  return (Request request) async {
    final apiKey = request.headers['x-api-key'];
    if (apiKey == null || apiKey.isEmpty) {
      return Response(401, body: jsonEncode({'error': 'API key required'}));
    }

    final origin = request.headers['origin'] ?? request.headers['host'] ?? '';
    final result = await apiKeyService.validateApiKey(apiKey, origin);

    if (result.isFailure) {
      return Response(403, body: jsonEncode({'error': result.error?.message}));
    }

    return handler(request);
  };
}
</code></pre>
<p><strong>C#:</strong></p>
<pre><code class="language-csharp">public class ApiKeyMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IApiKeyService _apiKeyService;

    public async Task InvokeAsync(HttpContext context)
    {
        if (!context.Request.Headers.TryGetValue("X-API-Key", out var apiKey))
        {
            context.Response.StatusCode = 401;
            await context.Response.WriteAsync("API key required");
            return;
        }

        var callerDomain = context.Request.Headers["Origin"].ToString()
            ?? context.Request.Host.Value;

        var result = await _apiKeyService.ValidateApiKey(apiKey, callerDomain);

        if (!result.IsSuccess)
        {
            context.Response.StatusCode = 403;
            await context.Response.WriteAsync(result.Error);
            return;
        }

        context.Items["ApiKeyContext"] = result.Value;
        await _next(context);
    }
}
</code></pre>
<h3 id="heading-the-real-problem-with-api-keys">The Real Problem With API Keys</h3>
<p>API keys are static. They don't expire automatically. A key found in a public GitHub repository, a Slack message, or a log file is a live credential until someone notices and rotates it.</p>
<p>One of the most common gaps I've seen in large-scale organizations is that APIs called by multiple clients have no domain allowlisting. The same key works from any domain. This makes it possible to share keys across clients and across environments, which means a staging key can call production, a mobile key can be used from a web client, and there's no real boundary between any of them.</p>
<p>No API key should work for multiple clients or multiple environments. Keys must be tied to specific allowed domains and specific environments. This isn't optional.</p>
<p>Never hardcode API keys in source code. Never commit them to Git. <code>.gitignore</code> is not enough. Keys must be fetched from a trusted secrets manager at runtime: vaults, Azure App Configuration, AWS Secrets Manager. API key rotation must be a scheduled organizational practice, not a reaction to a suspected leak.</p>
<h3 id="heading-when-to-use-it">When to Use It</h3>
<p>Use API keys for server-to-server communication, public APIs where you need to identify and rate-limit callers, and developer tools and integrations. Any scenario where a human user isn't the direct caller.</p>
<h2 id="heading-3-bearer-token-authentication">3. Bearer Token Authentication</h2>
<h3 id="heading-how-it-works">How It Works</h3>
<p>The client authenticates once through a login flow and receives a token. That token travels in the Authorization header on all subsequent requests.</p>
<pre><code class="language-plaintext">Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
</code></pre>
<p>There are no credentials after the initial login. The token is what travels. The server validates the token on every request.</p>
<p><strong>Dart:</strong></p>
<pre><code class="language-dart">class BearerTokenMiddleware {
  final TokenValidator _validator;

  BearerTokenMiddleware(this._validator);

  Handler call(Handler handler) {
    return (Request request) async {
      final authHeader = request.headers['authorization'];

      if (authHeader == null || !authHeader.startsWith('Bearer ')) {
        return Response(401, body: jsonEncode({'error': 'Bearer token required'}));
      }

      final token = authHeader.substring(7);
      final validationResult = await _validator.validate(token);

      if (validationResult.isFailure) {
        return Response(401, body: jsonEncode({'error': validationResult.error?.message}));
      }

      final updatedRequest = request.change(
        context: {'auth_claims': validationResult.value},
      );

      return handler(updatedRequest);
    };
  }
}
</code></pre>
<p><strong>C#:</strong></p>
<pre><code class="language-csharp">builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =&gt;
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!)
            ),
            ClockSkew = TimeSpan.Zero
        };
    });
</code></pre>
<h3 id="heading-the-real-problem-with-bearer-tokens">The Real Problem With Bearer Tokens</h3>
<p>Token theft is the biggest problem. If an attacker gets hold of a valid bearer token, they can use it until it expires or is manually revoked. This is why token expiry is not a nice-to-have. It's what limits the damage when a token is compromised. Short-lived access tokens with refresh token rotation is the correct pattern.</p>
<h3 id="heading-when-to-use-it">When to Use It</h3>
<p>They work well in most modern web and mobile APIs, user authentication flows, and in any API where stateless, scalable authentication is needed.</p>
<h2 id="heading-4-jwt-json-web-token">4. JWT – JSON Web Token</h2>
<h3 id="heading-how-it-works">How It Works</h3>
<p>JWTs are a specific format for bearer tokens, not a separate authentication mechanism. They're self-contained tokens that carry claims about the user inside the token itself.</p>
<p>A JWT has three parts separated by dots:</p>
<pre><code class="language-plaintext">Header.Payload.Signature
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIxMjMiLCJyb2xlIjoiYWRtaW4ifQ.SIGNATURE
</code></pre>
<p>The header specifies the signing algorithm. The payload carries the claims: user ID, role, expiry time, and issued at time. The signature is a cryptographic hash that proves the token came from your server and hasn't been tampered with.</p>
<p>The server validates the signature cryptographically and trusts the claims inside. No database lookup is needed. This is what makes JWTs stateless and scalable.</p>
<p><strong>Dart:</strong></p>
<pre><code class="language-dart">class JwtService {
  final String _secret;
  final String _issuer;
  final Duration _accessTokenExpiry;

  JwtService({
    required String secret,
    required String issuer,
    Duration accessTokenExpiry = const Duration(minutes: 15),
  })  : _secret = secret,
        _issuer = issuer,
        _accessTokenExpiry = accessTokenExpiry;

  String generateAccessToken(User user) {
    final now = DateTime.now();
    final payload = {
      'sub': user.id,
      'role': user.role.name,
      'iat': now.millisecondsSinceEpoch ~/ 1000,
      'exp': now.add(_accessTokenExpiry).millisecondsSinceEpoch ~/ 1000,
      'iss': _issuer,
      
    };

    return _sign(payload);
  }

  Result&lt;JwtClaims, AppException&gt; validateToken(String token) {
    try {
      final claims = _verifyAndDecode(token);

      final exp = claims['exp'] as int;
      if (DateTime.fromMillisecondsSinceEpoch(exp * 1000).isBefore(DateTime.now())) {
        return Result.failure(AppException.unauthorized('Token has expired'));
      }

      if (claims['iss'] != _issuer) {
        return Result.failure(AppException.unauthorized('Invalid token issuer'));
      }

      return Result.success(JwtClaims.fromMap(claims));
    } on SignatureVerificationException {
      return Result.failure(AppException.unauthorized('Invalid token signature'));
    } catch (e) {
      return Result.failure(AppException.unauthorized('Token validation failed'));
    }
  }
}
</code></pre>
<p><strong>C#:</strong></p>
<pre><code class="language-csharp">public class JwtService
{
    private readonly JwtSettings _settings;

    public string GenerateAccessToken(User user)
    {
        var securityKey = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(_settings.Secret)
        );

       
        var credentials = new SigningCredentials(
            securityKey,
            SecurityAlgorithms.HmacSha256
        );

        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, user.Id),
            new Claim(ClaimTypes.Role, user.Role.ToString()),
            new Claim(JwtRegisteredClaimNames.Iat,
                DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
           
        };

        var token = new JwtSecurityToken(
            issuer: _settings.Issuer,
            audience: _settings.Audience,
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(15),
            signingCredentials: credentials
        );

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}
</code></pre>
<h3 id="heading-jwt-failure-modes">JWT Failure Modes</h3>
<p>JWTs are excellent when implemented correctly. When implemented incorrectly, they can be catastrophic. Every failure is an implementation failure, not a standard failure. Here are the ones I've seen that cause real problems.</p>
<h4 id="heading-1-the-algorithm-confusion-attack">1. The algorithm confusion attack</h4>
<p>The JWT header specifies which algorithm was used to sign it. If your server accepts whatever algorithm the token claims, an attacker sets the algorithm to <code>none</code> and strips the signature entirely. Your server accepts any token as valid.</p>
<h4 id="heading-2-weak-signing-secrets">2. Weak signing secrets</h4>
<p>A weak JWT secret can be brute-forced offline. The attacker doesn't need access to your server. They take the token, run it through a cracker, discover the secret, and can now sign any token they want as any user they want.</p>
<p>Use cryptographically strong secrets of at least 256 bits. For high-security contexts use RS256 or ES256 with asymmetric keys.</p>
<h4 id="heading-3-no-expiry">3. No expiry</h4>
<p>A JWT without an <code>exp</code> claim never becomes invalid. Set an expiry, always. For short-lived access tokens, choose 15 minutes to 1 hour. Refresh tokens handle session continuity.</p>
<h4 id="heading-4-sensitive-data-in-the-payload">4. Sensitive data in the payload</h4>
<p>The payload is Base64 encoded, not encrypted. Anyone who gets the token can decode and read everything inside. Never put passwords, full account numbers, BVN, or sensitive PII in a JWT payload. Put identifiers instead. Let the server fetch the sensitive data.</p>
<h4 id="heading-5-no-revocation-strategy">5. No revocation strategy</h4>
<p>Because JWTs are stateless, the server doesn't track issued tokens. A stolen token is valid until it expires.</p>
<p>The fix: maintain a token blocklist for explicitly revoked tokens, or use short expiry with refresh token rotation so the damage window is small.</p>
<h3 id="heading-when-to-use-jwts">When to Use JWTs</h3>
<p>Use them for stateless APIs at scale, microservices where services need to verify identity without calling a central auth server on every request, and mobile applications. They're good for any system where stateless authentication scalability matters more than the complexity of managing token revocation.</p>
<h2 id="heading-5-oauth-20">5. OAuth 2.0</h2>
<h3 id="heading-how-it-works">How It Works</h3>
<p>OAuth 2.0 is an authorization framework, not an authentication protocol. It lets a user grant a third-party application access to their resources without sharing their password with that third party.</p>
<p>You have used this every time you logged into an app with Google, or saw "Allow this app to access your account."</p>
<p>Four parties are involved: the Authorization Server that issues tokens, the Resource Server that hosts the protected API, the Client which is the application requesting access, and the Resource Owner which is the user.</p>
<p>The Authorization Code flow works like this:</p>
<ol>
<li><p>The client redirects the user to the Authorization Server with a request for specific scopes</p>
</li>
<li><p>The user authenticates and approves the requested scopes</p>
</li>
<li><p>The Authorization Server redirects back to the client with an authorization code</p>
</li>
<li><p>The client exchanges the code for an access token server-side</p>
</li>
<li><p>The client uses the access token to call the Resource Server</p>
</li>
</ol>
<p><strong>Dart:</strong></p>
<pre><code class="language-dart">class OAuthClient {
  final String _clientId;
  final String _clientSecret;
  final String _redirectUri;
  final String _authorizationEndpoint;
  final String _tokenEndpoint;

  OAuthClient({
    required String clientId,
    required String clientSecret,
    required String redirectUri,
    required String authorizationEndpoint,
    required String tokenEndpoint,
  })  : _clientId = clientId,
        _clientSecret = clientSecret,
        _redirectUri = redirectUri,
        _authorizationEndpoint = authorizationEndpoint,
        _tokenEndpoint = tokenEndpoint;

  String buildAuthorizationUrl(List&lt;String&gt; scopes) {
   
    final state = _generateSecureState();

    final params = {
      'response_type': 'code',
      'client_id': _clientId,
      'redirect_uri': _redirectUri,
      'scope': scopes.join(' '),
      'state': state,
    };

    final uri = Uri.parse(_authorizationEndpoint)
        .replace(queryParameters: params);

    return uri.toString();
  }

  Future&lt;Result&lt;OAuthTokens, AppException&gt;&gt; exchangeCodeForTokens(
    String code,
    String state,
    String expectedState,
  ) async {
   
    if (state != expectedState) {
      return Result.failure(
        AppException.unauthorized('Invalid state parameter'),
      );
    }

    final response = await http.post(
      Uri.parse(_tokenEndpoint),
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: {
        'grant_type': 'authorization_code',
        'code': code,
        'redirect_uri': _redirectUri,
        'client_id': _clientId,
        'client_secret': _clientSecret,
      },
    );

    if (response.statusCode != 200) {
      return Result.failure(AppException.unauthorized('Token exchange failed'));
    }

    final tokens = OAuthTokens.fromJson(jsonDecode(response.body));
    return Result.success(tokens);
  }

  String _generateSecureState() {
    final bytes = List&lt;int&gt;.generate(32, (_) =&gt; Random.secure().nextInt(256));
    return base64Url.encode(bytes);
  }
}
</code></pre>
<p><strong>C#:</strong></p>
<pre><code class="language-csharp">builder.Services.AddAuthentication(options =&gt;
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = "OAuth2";
})
.AddCookie()
.AddOAuth("OAuth2", options =&gt;
{
    options.ClientId = builder.Configuration["OAuth:ClientId"]!;
    options.ClientSecret = builder.Configuration["OAuth:ClientSecret"]!;
    options.CallbackPath = "/auth/callback";
    options.AuthorizationEndpoint = "https://auth.provider.com/authorize";
    options.TokenEndpoint = "https://auth.provider.com/token";
    options.SaveTokens = true;
    options.Scope.Add("openid");
    options.Scope.Add("profile");

    options.Events = new OAuthEvents
    {
        OnCreatingTicket = async context =&gt;
        {
            var userInfoRequest = new HttpRequestMessage(
                HttpMethod.Get,
                "https://auth.provider.com/userinfo"
            );
            userInfoRequest.Headers.Authorization =
                new AuthenticationHeaderValue("Bearer", context.AccessToken);

            var response = await context.Backchannel.SendAsync(userInfoRequest);
            var userInfo = await response.Content.ReadFromJsonAsync&lt;JsonDocument&gt;();

            context.Identity!.AddClaim(new Claim(
                ClaimTypes.NameIdentifier,
                userInfo!.RootElement.GetString("sub")!
            ));
        }
    };
});
</code></pre>
<h3 id="heading-oauth-20-failure-modes">OAuth 2.0 Failure Modes</h3>
<h4 id="heading-1-misconfigured-redirect-uris">1. Misconfigured redirect URIs</h4>
<p>If the authorization server doesn't strictly validate redirect URIs, an attacker substitutes their own URI and intercepts the authorization code. Use exact match validation to prevent this.</p>
<h4 id="heading-2-tokens-in-urls">2. Tokens in URLs</h4>
<p>Access tokens can appear in server logs, browser history, or referrer headers because someone put them in a query parameter instead of an Authorization header. Tokens belong in Authorization headers. URLs appear in logs, while authorization headers don't.</p>
<h3 id="heading-when-to-use-oauth-20">When to Use OAuth 2.0</h3>
<p>It works well in any scenario where a third party needs delegated access to user resources, like social login, API integrations between organizations, or partner system integrations.</p>
<h2 id="heading-6-openid-connect-oidc">6. OpenID Connect (OIDC)</h2>
<h3 id="heading-how-it-works">How It Works</h3>
<p>OAuth 2.0 handles authorization. OpenID Connect adds authentication on top of OAuth 2.0. It doesn't just tell you what the user approved. It tells you who the user actually is.</p>
<p>OIDC issues an ID token alongside the access token. The ID token is a JWT containing verified identity claims: subject identifier, email, name, profile picture, and when the authentication happened.</p>
<p><strong>Dart:</strong></p>
<pre><code class="language-dart">class OidcService {
  final String _issuer;
  final String _clientId;
  final JwtValidator _jwtValidator;

  OidcService({
    required String issuer,
    required String clientId,
    required JwtValidator jwtValidator,
  })  : _issuer = issuer,
        _clientId = clientId,
        _jwtValidator = jwtValidator;

  Future&lt;Result&lt;UserIdentity, AppException&gt;&gt; validateIdToken(
    String idToken,
  ) async {
    final claimsResult = await _jwtValidator.validate(idToken);
    if (claimsResult.isFailure) {
      return Result.failure(claimsResult.error!);
    }

    final claims = claimsResult.value!;

    if (claims['iss'] != _issuer) {
      return Result.failure(AppException.unauthorized('Invalid token issuer'));
    }


    final aud = claims['aud'];
    final audiences = aud is List ? aud : [aud];
    if (!audiences.contains(_clientId)) {
      return Result.failure(
        AppException.unauthorized('Token not intended for this client'),
      );
    }

    return Result.success(UserIdentity(
      subject: claims['sub'] as String,
      email: claims['email'] as String?,
      name: claims['name'] as String?,
      emailVerified: claims['email_verified'] as bool? ?? false,
    ));
  }
}
</code></pre>
<p><strong>C#:</strong></p>
<pre><code class="language-csharp">builder.Services.AddAuthentication(options =&gt;
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =&gt;
{
    options.Authority = "https://accounts.google.com";
    options.ClientId = builder.Configuration["OIDC:ClientId"]!;
    options.ClientSecret = builder.Configuration["OIDC:ClientSecret"]!;
    options.ResponseType = "code";
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("email");
    options.SaveTokens = true;
    options.GetClaimsFromUserInfoEndpoint = true;

    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        NameClaimType = "name",
        RoleClaimType = "role"
    };
});
</code></pre>
<h3 id="heading-when-to-use-oidc">When to Use OIDC</h3>
<p>It's good for any application that needs to verify user identity through a trusted identity provider, like enterprise SSO or social login. It works well for any system where you want to delegate identity verification to a trusted third party rather than managing user credentials yourself.</p>
<p>Google Sign-In, Microsoft Azure AD, Okta, Auth0 all implement OIDC. If your question is "who is this person" rather than just "is this request authorized," OIDC is the right framework.</p>
<h2 id="heading-7-mutual-tls-mtls">7. Mutual TLS (mTLS)</h2>
<h3 id="heading-how-it-works">How It Works</h3>
<p>In regular TLS, the client verifies the server's certificate. The server trusts any client that can establish a connection.</p>
<p>In mutual TLS, both parties verify each other's certificates. The server only accepts connections from clients with a certificate issued by a trusted Certificate Authority. The client can't fake its identity because it needs a real certificate.</p>
<p>There's no bearer token or API key. The certificate is the authentication.</p>
<p><strong>Dart (client-side mTLS):</strong></p>
<pre><code class="language-dart">class MtlsHttpClient {
  final http.Client _client;

  MtlsHttpClient._internal(this._client);

  static Future&lt;MtlsHttpClient&gt; create({
    required String certificatePath,
    required String privateKeyPath,
    required String trustedCaPath,
  }) async {
    final context = SecurityContext(withTrustedRoots: false);

    // load the client certificate
    context.useCertificateChainBytes(
      await File(certificatePath).readAsBytes(),
    );

    // load the client private key
    context.usePrivateKeyBytes(
      await File(privateKeyPath).readAsBytes(),
    );

    // only trust this specific CA
    context.setTrustedCertificatesBytes(
      await File(trustedCaPath).readAsBytes(),
    );

    final httpClient = HttpClient(context: context);
    final client = IOClient(httpClient);

    return MtlsHttpClient._internal(client);
  }

  Future&lt;http.Response&gt; get(String url, {Map&lt;String, String&gt;? headers}) {
    return _client.get(Uri.parse(url), headers: headers);
  }

  Future&lt;http.Response&gt; post(
    String url, {
    Map&lt;String, String&gt;? headers,
    Object? body,
  }) {
    return _client.post(Uri.parse(url), headers: headers, body: body);
  }
}
</code></pre>
<p><strong>C#:</strong></p>
<pre><code class="language-csharp">builder.WebHost.ConfigureKestrel(options =&gt;
{
    options.ConfigureHttpsDefaults(httpsOptions =&gt;
    {
        httpsOptions.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
        httpsOptions.ClientCertificateValidation = (certificate, chain, errors) =&gt;
        {
            if (errors != SslPolicyErrors.None)
                return false;

            var expectedThumbprint = "your-trusted-cert-thumbprint";
            return certificate.Thumbprint == expectedThumbprint;
        };
    });
});

public class MtlsMiddleware
{
    private readonly RequestDelegate _next;

    public async Task InvokeAsync(HttpContext context)
    {
        var clientCert = context.Connection.ClientCertificate;

        if (clientCert == null)
        {
            context.Response.StatusCode = 401;
            await context.Response.WriteAsync("Client certificate required");
            return;
        }

        if (!IsValidClientCertificate(clientCert))
        {
            context.Response.StatusCode = 403;
            await context.Response.WriteAsync("Invalid client certificate");
            return;
        }

        await _next(context);
    }

    private bool IsValidClientCertificate(X509Certificate2 cert)
    {
        if (cert.NotAfter &lt; DateTime.UtcNow) return false;

        var trustedThumbprints = new HashSet&lt;string&gt;
        {
            "THUMBPRINT_SERVICE_A",
            "THUMBPRINT_SERVICE_B",
        };

        return trustedThumbprints.Contains(cert.Thumbprint);
    }
}
</code></pre>
<h3 id="heading-the-real-problem-with-mtls">The Real Problem With mTLS</h3>
<p>The issue here is certificate management. Certificates expire. If rotation isn't automated, an expired certificate breaks service-to-service communication at the worst possible time. The CA infrastructure itself must be secured. A compromised CA means every certificate it issued is compromised.</p>
<p>For teams without the operational capacity to manage a full PKI, API keys with strict domain allowlisting and rotation schedules may be the more practical answer. mTLS is the right architecture, but it's also the most demanding to operate correctly.</p>
<h3 id="heading-when-to-use-mtls">When to Use mTLS</h3>
<p>Use mTLS for high-security service-to-service communication, like payment gateways or banking APIs. It also works well for microservices in regulated environments where compliance requires cryptographic proof of identity at the transport layer. Every communication between internal services in a high-security system should be treated as sensitive. mTLS provides that foundation.</p>
<h2 id="heading-choosing-the-right-mechanism">Choosing the Right Mechanism</h2>
<p>Choosing an authentication mechanism is an architectural decision. Here's the framework:</p>
<p><strong>For user-facing web and mobile applications:</strong> choose bearer tokens with JWT. Short-lived access tokens with refresh token rotation. Rate limiting on all authentication endpoints.</p>
<p><strong>For third-party delegated access:</strong> use OAuth 2.0. When you also need to verify user identity, add OIDC on top.</p>
<p><strong>For enterprise SSO:</strong> choose OpenID Connect with an enterprise identity provider.</p>
<p><strong>For server-to-server, lower security requirements:</strong> use API keys with domain allowlisting, environment-specific keys, and scheduled rotation.</p>
<p><strong>For service-to-service in regulated high-security environments:</strong> use mTLS.</p>
<p><strong>For internal tooling in tightly controlled environments:</strong> Basic Auth is the floor, and only if TLS is guaranteed. For everything else, use something stronger.</p>
<p>You should choose your authentication mechanism based on who the caller is, what the data sensitivity is, and what the compliance requirements are. Not by convention or by what the last project used. And not by what's easiest to implement.</p>
<h2 id="heading-the-organizational-discipline-that-ties-everything-together">The Organizational Discipline That Ties Everything Together</h2>
<p>Getting the technical implementation right is half the work. The other half is organizational.</p>
<p>API key rotation must be scheduled and proactive, not a reaction to a suspected leak. Every key has a defined maximum lifetime and a rotation schedule that engineering owns.</p>
<p>No API key works for multiple clients or multiple environments. A staging key is a staging key. A mobile client key is a mobile client key. Cross-environment key reuse is a security boundary collapse.</p>
<p>Secrets never live in source code, application code, or in configuration files committed to the repository. Secrets are fetched from a trusted secrets manager at runtime. This is an engineering standard, not a preference.</p>
<p>Encryption algorithms must be an organizational standard, not a developer decision made per project. Someone at the staff engineer or architect level defines which algorithm, which mode, and which key length the organization uses. Every project uses the standard, and no developer implements encryption from scratch per project.</p>
<p>Organizations should build and maintain internally shared packages for cryptographic operations. Developers import the package. They never write encryption themselves. This eliminates an entire category of cryptographic implementation failures that come from ad-hoc choices.</p>
<p>Structured logging with field-level masking must be enforced at the framework level. Authorization headers, tokens, passwords, account numbers, and any sensitive field must never appear in logs in any environment (production, staging, or development). The masking applies at the logging framework level so no individual developer can accidentally log sensitive data even if they try.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Every mechanism in this article works when implemented correctly. And at the same time, every mechanism fails in predictable, documented ways when implemented incorrectly.</p>
<p>JWT algorithm confusion attacks have caused real authentication bypasses. Misconfigured OAuth redirect URIs have caused real account takeovers. Missing state parameters have enabled real CSRF attacks. Weak signing secrets have led to real impersonation at scale. Hardcoded API keys in repositories have exposed production systems to unauthorized access.</p>
<p>These aren't theoretical failure modes. They appear in production systems at organizations of every size. The engineers who built those systems weren't incompetent. They just weren't told about the failure modes. Nobody defined the organizational standard or enforced it in the pipeline.</p>
<p>The engineering discipline is knowing exactly how each mechanism breaks before you write the first line of code, and building your implementation to avoid those failure modes from the start. That's the difference between authentication that works and authentication that holds up under adversarial conditions.</p>
<p>Happy Coding!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Why You Should Never Send Emails Inside Your API Requests ]]>
                </title>
                <description>
                    <![CDATA[ Your signup endpoint looks fine in development. A user registers, you save the row, you call your email provider, and you return 201 Created. Everyone gets a welcome email. You ship it. Then productio ]]>
                </description>
                <link>https://www.freecodecamp.org/news/why-you-should-never-send-emails-inside-your-api-requests/</link>
                <guid isPermaLink="false">6a97368f8a7f1f043a223d7c</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ backend ]]>
                    </category>
                
                    <category>
                        <![CDATA[ architecture ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chinedu Otutu ]]>
                </dc:creator>
                <pubDate>Tue, 01 Sep 2026 20:33:19 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8da5a9af-d7e5-4d0e-94cc-a11d24923023.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Your signup endpoint looks fine in development. A user registers, you save the row, you call your email provider, and you return <code>201 Created</code>. Everyone gets a welcome email. You ship it.</p>
<p>Then production traffic arrives.</p>
<p>The email API takes 2 seconds instead of 200 milliseconds. A few requests time out. During a provider outage, every signup starts returning <code>500</code>. Support asks why users can create accounts but never get the confirmation link.</p>
<p>The bug isn't your email template. The bug is where you decided to send the email.</p>
<p>In this article, you'll learn why sending email inside the HTTP request path is a production trap, what goes wrong as you scale, and how to move email (and other side effects) into a background job queue so your API stays fast and reliable.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You'll get more out of this article if you know:</p>
<ul>
<li><p>How REST APIs work (request, response, status codes)</p>
</li>
<li><p>Basic Node.js or any backend language that can call an email API</p>
</li>
<li><p>What a background job or queue is at a high level</p>
</li>
</ul>
<p>You don't need prior experience with BullMQ, Redis, or a specific email provider.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-1-what-inside-the-request-really-means">1. What "Inside the Request" Really Means</a></p>
</li>
<li><p><a href="#heading-2-the-tempting-code-that-causes-the-problem">2. The Tempting Code That Causes the Problem</a></p>
</li>
<li><p><a href="#heading-3-why-this-breaks-in-production">3. Why This Breaks in Production</a></p>
</li>
<li><p><a href="#heading-4-the-rule-your-api-should-enqueue-work-not-do-it">4. The Rule: Your API Should Enqueue Work, Not Do It</a></p>
</li>
<li><p><a href="#heading-5-how-to-move-email-to-a-background-queue">5. How to Move Email to a Background Queue</a></p>
</li>
<li><p><a href="#heading-6-how-to-handle-retries-failures-and-idempotency">6. How to Handle Retries, Failures, and Idempotency</a></p>
</li>
<li><p><a href="#heading-7-what-else-should-leave-the-request-path">7. What Else Should Leave the Request Path</a></p>
</li>
<li><p><a href="#heading-8-a-practical-checklist">8. A Practical Checklist</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-1-what-inside-the-request-really-means">1. What "Inside the Request" Really Means</h2>
<p>When a client hits your API, the request path is everything that must finish before you send the HTTP response.</p>
<p>That usually includes:</p>
<ul>
<li><p>validating input</p>
</li>
<li><p>checking auth</p>
</li>
<li><p>writing to your database</p>
</li>
<li><p>returning JSON</p>
</li>
</ul>
<p>It should almost never include:</p>
<ul>
<li><p>calling an SMTP server</p>
</li>
<li><p>waiting on Resend, SendGrid, SES, Mailgun, or Postmark</p>
</li>
<li><p>rendering a big HTML template and uploading attachments</p>
</li>
<li><p>retrying a flaky third-party network call</p>
</li>
</ul>
<p>Email providers are external systems. They have their own latency, rate limits, and outages. If your API waits on them, their problems become your users' problems.</p>
<pre><code class="language-text">Bad path (synchronous):

Client ──▶ API ──▶ Database ──▶ Email provider ──▶ Client
                         ▲
                         └── user waits for all of this
</code></pre>
<h2 id="heading-2-the-tempting-code-that-causes-the-problem">2. The Tempting Code That Causes the Problem</h2>
<p>Here's the pattern most teams start with. It's clear, short, and works on your laptop.</p>
<pre><code class="language-typescript">app.post('/signup', async (c) =&gt; {
  const body = await c.req.json();

  const user = await db.users.create({
    email: body.email,
    passwordHash: await hash(body.password),
  });

  // This is the trap.
  await emailClient.send({
    to: user.email,
    subject: 'Confirm your account',
    html: renderConfirmEmail(user),
  });

  return c.json({ id: user.id }, 201);
});
</code></pre>
<p>The request can't finish until the email provider answers. If that call is slow, your response is slow. If that call throws, your signup either fails or you catch the error and silently skip the email.</p>
<p>Neither option is good.</p>
<h2 id="heading-3-why-this-breaks-in-production">3. Why This Breaks in Production</h2>
<h3 id="heading-your-response-time-becomes-someone-elses-sla">Your Response Time Becomes Someone Else's SLA</h3>
<p>A healthy signup endpoint should often respond in tens or hundreds of milliseconds. Email APIs regularly take longer than that. Under load, they take much longer.</p>
<p>Your p95 latency is no longer owned by your code. It's owned by the slowest third-party call on the path.</p>
<h3 id="heading-failures-create-ugly-tradeoffs">Failures Create Ugly Tradeoffs</h3>
<p>If <code>send()</code> throws, you have two bad choices:</p>
<ol>
<li><p>Fail the whole request: the user sees an error even though the account may already exist</p>
</li>
<li><p>Swallow the error: the user is created, but never gets the email</p>
</li>
</ol>
<p>That second case is especially painful with confirmation or password-reset flows. The database says success but the inbox says nothing.</p>
<h3 id="heading-timeouts-cascade">Timeouts Cascade</h3>
<p>API gateways, load balancers, and browsers all have timeouts. A slow email call can turn a successful business action into a client-side failure. The user retries. Now you may create duplicate users or send duplicate emails.</p>
<h3 id="heading-rate-limits-punish-traffic-spikes">Rate Limits Punish Traffic Spikes</h3>
<p>Email providers rate-limit you. Product launches, digests, and password-reset spikes can all hit those limits. If sending happens inside the request, a rate limit error becomes a user-facing API error.</p>
<h3 id="heading-you-cant-retry-cleanly">You Can't Retry Cleanly</h3>
<p>Background jobs can retry with backoff. HTTP requests can't. Once you've returned a response, the request is over. If the email failed and you already told the client "success," you need another system to repair that. That system is a queue.</p>
<pre><code class="language-text">What the user experiences during a provider slowdown:

Signup click
   │
   ▼
API waits on email provider .............. 2s ... 5s ... timeout
   │
   ▼
"Something went wrong"
   │
   ▼
User clicks again → duplicate attempts, confused support tickets
</code></pre>
<h2 id="heading-4-the-rule-your-api-should-enqueue-work-not-do-it">4. The Rule: Your API Should Enqueue Work, Not Do It</h2>
<p>Treat email as asynchronous work that must happen <em>because of</em> the request, not <em>inside</em> the request.</p>
<p>The request path should:</p>
<ol>
<li><p>validate the input</p>
</li>
<li><p>commit the business change</p>
</li>
<li><p>enqueue a job that describes the email</p>
</li>
<li><p>return quickly</p>
</li>
</ol>
<p>A worker somewhere else should:</p>
<ol>
<li><p>pick up the job</p>
</li>
<li><p>render the template</p>
</li>
<li><p>call the email provider</p>
</li>
<li><p>retry on failure</p>
</li>
<li><p>record the result</p>
</li>
</ol>
<pre><code class="language-text">Good path (asynchronous):

Client ──▶ API ──▶ Database ──▶ Queue ──▶ Client (fast response)
                                   │
                                   ▼
                                Worker ──▶ Email provider
</code></pre>
<p>This is the same idea behind processing uploads, generating PDFs, calling AI APIs, or syncing data to a CRM. Email is just the most common example.</p>
<h2 id="heading-5-how-to-move-email-to-a-background-queue">5. How to Move Email to a Background Queue</h2>
<p>Here's a minimal Node.js shape using <a href="https://docs.bullmq.io/">BullMQ</a> and Redis. The exact library matters less than the boundary.</p>
<h3 id="heading-define-the-job">Define the Job</h3>
<pre><code class="language-typescript">// jobs/email.ts
export type SendEmailJob = {
  to: string;
  template: 'confirm-account' | 'password-reset' | 'welcome';
  variables: Record&lt;string, string&gt;;
  idempotencyKey: string;
};
</code></pre>
<h3 id="heading-enqueue-from-the-api">Enqueue from the API</h3>
<pre><code class="language-typescript">import { emailQueue } from '../queues/email-queue';

app.post('/signup', async (c) =&gt; {
  const body = await c.req.json();

  const user = await db.users.create({
    email: body.email,
    passwordHash: await hash(body.password),
  });

  await emailQueue.add(
    'send-email',
    {
      to: user.email,
      template: 'confirm-account',
      variables: {
        name: user.name,
        confirmUrl: `https://example.com/confirm/${user.confirmToken}`,
      },
      idempotencyKey: `confirm-account:${user.id}`,
    },
    {
      jobId: `confirm-account:${user.id}`,
      attempts: 5,
      backoff: { type: 'exponential', delay: 2000 },
      removeOnComplete: 1000,
      removeOnFail: 5000,
    },
  );

  return c.json({ id: user.id }, 201);
});
</code></pre>
<p>Notice what changed. The API still creates the user. It no longer waits for SMTP. It waits for Redis to accept a job, which is usually far cheaper and more predictable than waiting for an email provider.</p>
<h3 id="heading-process-the-job-in-a-worker">Process the Job in a Worker</h3>
<pre><code class="language-typescript">// worker/email-worker.ts
import { Worker } from 'bullmq';
import { emailClient } from '../lib/email-client';
import { renderTemplate } from '../lib/templates';

new Worker(
  'email',
  async (job) =&gt; {
    const { to, template, variables } = job.data;

    await emailClient.send({
      to,
      subject: subjectFor(template),
      html: renderTemplate(template, variables),
    });
  },
  {
    connection: redisConnection,
    concurrency: 5,
  },
);
</code></pre>
<p>Run the worker as a separate process from the API. That way an email outage slows the worker, not every HTTP response.</p>
<h3 id="heading-return-the-right-status">Return the Right Status</h3>
<p>For signup, <code>201 Created</code> is still correct if the account exists and the confirmation email is queued.</p>
<p>For endpoints that only trigger work, prefer <code>202 Accepted</code> and return a job or delivery id when the client needs to track progress.</p>
<h2 id="heading-6-how-to-handle-retries-failures-and-idempotency">6. How to Handle Retries, Failures, and Idempotency</h2>
<p>Moving email to a queue isn't enough. You also need failure rules.</p>
<h3 id="heading-retry-transient-errors">Retry Transient Errors</h3>
<p>Network blips, <code>429 Too Many Requests</code>, and <code>503 Service Unavailable</code> should retry with backoff. Permanent errors like an invalid recipient should fail fast and land in a dead-letter queue or failed-jobs table.</p>
<h3 id="heading-make-jobs-idempotent">Make Jobs Idempotent</h3>
<p>Workers can run more than once. Use a stable <code>jobId</code> or <code>idempotencyKey</code> so a retry doesn't send five welcome emails for one signup.</p>
<p>A practical approach:</p>
<ul>
<li><p>derive the key from the business event: <code>confirm-account:user_123</code></p>
</li>
<li><p>store provider message ids when a send succeeds</p>
</li>
<li><p>skip work if that key was already delivered</p>
</li>
</ul>
<h3 id="heading-dont-lose-the-business-event">Don't Lose the Business Event</h3>
<p>If the process crashes between <code>db.users.create()</code> and <code>emailQueue.add()</code>, you can lose the email.</p>
<p>There are two common ways to reduce that risk:</p>
<ol>
<li><p><strong>Outbox pattern</strong>: write the email intent into an <code>outbox</code> table in the same database transaction as the user row. A separate publisher reads the outbox and enqueues jobs.</p>
</li>
<li><p><strong>Transactional enqueue where supported</strong>: keep the write and the enqueue as close together as possible, and run reconciliation jobs for missed sends.</p>
</li>
</ol>
<p>For many apps, a simple queue plus monitoring is enough at first. Add an outbox when missed emails become a real reliability requirement.</p>
<h3 id="heading-separate-urgent-mail-from-bulk-mail">Separate Urgent Mail from Bulk Mail</h3>
<p>Password reset and OTP emails shouldn't sit behind a newsletter blast.</p>
<p>Use priorities or separate queues:</p>
<ul>
<li><p>high priority: OTP, password reset, confirmation</p>
</li>
<li><p>normal priority: receipts, onboarding</p>
</li>
<li><p>low priority: digests, marketing</p>
</li>
</ul>
<p>That keeps user-blocking email fast even when bulk traffic spikes.</p>
<h2 id="heading-7-what-else-should-leave-the-request-path">7. What Else Should Leave the Request Path</h2>
<p>If a task calls a third-party network API, can take more than a few hundred milliseconds, or it needs retries, keep it out of the request path. Same if it can fail without undoing the main business action or it does CPU-heavy work like image or PDF generation.</p>
<p>Here are some common examples of these types of tasks:</p>
<ul>
<li><p>emails and SMS</p>
</li>
<li><p>webhook fan-out</p>
</li>
<li><p>search indexing</p>
</li>
<li><p>thumbnail generation</p>
</li>
<li><p>invoice PDF creation</p>
</li>
<li><p>CRM sync</p>
</li>
<li><p>AI transcription or summarization</p>
</li>
<li><p>slow report generation</p>
</li>
</ul>
<p>Your API should confirm the intent and persist the state. Workers should do the slow parts.</p>
<h2 id="heading-8-a-practical-checklist">8. A Practical Checklist</h2>
<p>Before you ship a feature that sends email, ask:</p>
<ol>
<li><p>Does the user need the email to finish before seeing success?</p>
</li>
<li><p>Can the email provider being down break this endpoint?</p>
</li>
<li><p>If sending fails after the database write, how do we recover?</p>
</li>
<li><p>If the job runs twice, will the user get duplicate messages?</p>
</li>
<li><p>Do OTP and password-reset emails have a faster lane than bulk email?</p>
</li>
<li><p>Can we see failed jobs in logs, metrics, or a dashboard?</p>
</li>
<li><p>Do we alert when the queue depth or failure rate spikes?</p>
</li>
</ol>
<p>If you can't answer those questions, the design isn't ready for production.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Sending email inside your API request couples your response time and your error rate to a third-party system you don't control. It feels simple in a demo and becomes expensive in production.</p>
<p>The better default is simple:</p>
<ul>
<li><p>commit the business change</p>
</li>
<li><p>enqueue the email</p>
</li>
<li><p>return quickly</p>
</li>
<li><p>let a worker send, retry, and report failures</p>
</li>
</ul>
<p>That pattern makes your API faster, your email delivery more reliable, and your failure modes much easier to reason about.</p>
<p>Once you make that split for email, you'll start seeing the same boundary everywhere: if the user shouldn't wait for it, it doesn't belong in the request.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Fix a Leaked API Key: A Developer’s Guide to Git Security ]]>
                </title>
                <description>
                    <![CDATA[ Imagine this: you're working late, your code finally works, and you're ready to push it to GitHub. You run: git add . git commit -m "Fix API integration" git push A few minutes later, you notice some ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-fix-a-leaked-api-key/</link>
                <guid isPermaLink="false">6a8ddd55902e76128f1985bb</guid>
                
                    <category>
                        <![CDATA[ Git ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Eva J Patel ]]>
                </dc:creator>
                <pubDate>Tue, 25 Aug 2026 18:22:13 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/0903575c-822b-481b-af12-07b864bebc67.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Imagine this: you're working late, your code finally works, and you're ready to push it to GitHub.</p>
<p>You run:</p>
<pre><code class="language-bash">git add .
git commit -m "Fix API integration"
git push
</code></pre>
<p>A few minutes later, you notice something strange. Your API usage has suddenly increased. Maybe there are unexpected requests, new cloud resources, or even a bill that looks much larger than expected.</p>
<p>Then you find it:</p>
<pre><code class="language-javascript">const apiKey = "sk_live_123456789";
</code></pre>
<p>Your API key is sitting in a Git repository.</p>
<p>This situation is stressful, but it's fixable.</p>
<p>The most important rule is:</p>
<blockquote>
<p><strong>If an API key has been committed to Git, assume it has been copied and compromised, even if you delete it immediately.</strong></p>
</blockquote>
<p>Deleting the key from the latest version of your file doesn't make the old key safe. Git keeps previous versions of files in its history, and exposed credentials can be discovered by automated scanners.</p>
<p>In this guide, you'll learn the following:</p>
<ul>
<li><p><a href="#heading-what-is-an-api-key">What Is an API Key?</a></p>
</li>
<li><p><a href="#heading-the-emergency-response-what-to-do-first">The Emergency Response: What to Do First</a></p>
</li>
<li><p><a href="#heading-step-1-revoke-or-rotate-the-leaked-key">Step 1: Revoke or Rotate the Leaked Key</a></p>
</li>
<li><p><a href="#heading-step-2-investigate-suspicious-activity">Step 2: Investigate Suspicious Activity</a></p>
</li>
<li><p><a href="#heading-step-3-remove-the-secret-from-your-current-code">Step 3: Remove the Secret From Your Current Code</a></p>
</li>
<li><p><a href="#heading-step-4-use-a-env-file-for-local-development">Step 4: Use a.envFile for Local Development</a></p>
</li>
<li><p><a href="#heading-step-5-create-a-safe-envexample">Step 5: Create a Safe.env.example</a></p>
</li>
<li><p><a href="#heading-step-6-determine-whether-the-secret-is-still-in-git-history">Step 6: Determine Whether the Secret Is Still in Git History</a></p>
</li>
<li><p><a href="#heading-when-do-you-need-to-rewrite-git-history">When Do You Need to Rewrite Git History?</a></p>
</li>
<li><p><a href="#heading-step-7-remove-the-secret-from-git-history">Step 7: Remove the Secret From Git History</a></p>
</li>
<li><p><a href="#heading-step-8-verify-that-the-secret-is-gone">Step 8: Verify That the Secret Is Gone</a></p>
</li>
<li><p><a href="#heading-step-9-push-the-cleaned-history-carefully">Step 9: Push the Cleaned History Carefully</a></p>
</li>
<li><p><a href="#heading-step-10-replace-the-credential-everywhere">Step 10: Replace the Credential Everywhere</a></p>
</li>
<li><p><a href="#heading-step-11-restrict-the-replacement-key">Step 11: Restrict the Replacement Key</a></p>
</li>
<li><p><a href="#heading-what-about-frontend-applications">What About Frontend Applications?</a></p>
</li>
<li><p><a href="#heading-environment-variables-vs-secret-managers">Environment Variables vs Secret Managers</a></p>
</li>
<li><p><a href="#heading-add-secret-scanning-to-your-workflow">Add Secret Scanning to Your Workflow</a></p>
</li>
<li><p><a href="#heading-use-git-hooks-as-an-extra-safety-net">Use Git Hooks as an Extra Safety Net</a></p>
</li>
<li><p><a href="#heading-review-your-staged-diff-before-committing">Review Your Staged Diff Before Committing</a></p>
</li>
<li><p><a href="#heading-common-mistakes-developers-make">Common Mistakes Developers Make</a></p>
</li>
<li><p><a href="#heading-a-complete-api-key-incident-checklist">A Complete API-Key Incident Checklist</a></p>
</li>
<li><p><a href="#heading-a-secure-project-structure">A Secure Project Structure</a></p>
</li>
</ul>
<p>We'll use this basic workflow throughout the article:</p>
<pre><code class="language-text">Invalidate → Investigate → Remove → Replace → Prevent
</code></pre>
<p>Let's start with what an API key actually is before we get to the most important part: what to do <strong>right now</strong> after a key is exposed.</p>
<h2 id="heading-what-is-an-api-key">What Is an API Key?</h2>
<p>An API key is a credential that allows an application to communicate with another service.</p>
<p>For example, an application might use an API key to access:</p>
<ul>
<li><p>A weather service</p>
</li>
<li><p>A payment provider</p>
</li>
<li><p>A mapping service</p>
</li>
<li><p>An artificial intelligence API</p>
</li>
<li><p>A cloud platform</p>
</li>
<li><p>A database</p>
</li>
<li><p>An email provider</p>
</li>
<li><p>A private company API</p>
</li>
</ul>
<p>A key might look something like this:</p>
<pre><code class="language-javascript">const apiKey = "your-real-api-key";
</code></pre>
<p>Or it might appear in a configuration file:</p>
<pre><code class="language-json">{
  "apiKey": "your-real-api-key",
  "databasePassword": "your-real-password"
}
</code></pre>
<p>API keys are often called <strong>secrets</strong> because possessing one may allow someone to make requests, access data, create resources, or generate charges on your account.</p>
<p>Not every API key is equally sensitive. Some services provide browser keys that are intentionally visible to users. Those keys should still have appropriate restrictions, quotas, and permissions.</p>
<p>As a general rule:</p>
<blockquote>
<p><strong>If a credential can access private data, create resources, modify records, or generate charges, it shouldn't be stored directly in your source code.</strong></p>
</blockquote>
<h2 id="heading-the-emergency-response-what-to-do-first">The Emergency Response: What to Do First</h2>
<p>When you discover a leaked credential, a common reaction is to delete the key from the file and push another commit.</p>
<p>Don't start there.</p>
<p>Your first priority is to <strong>make the leaked credential useless</strong>.</p>
<p>Use this order of operations:</p>
<pre><code class="language-text">1. Invalidate the leaked credential
2. Investigate suspicious activity
3. Remove the secret from your code
4. Replace it with a new credential
5. Clean the Git history if necessary
6. Verify the cleanup
7. Add protections against future leaks
</code></pre>
<p>Think of an API key like a house key that was dropped in a crowded street.</p>
<p>Deleting a picture of the key doesn't matter if someone already picked up the physical key.</p>
<p><strong>Change the lock first.</strong></p>
<h2 id="heading-step-1-revoke-or-rotate-the-leaked-key">Step 1: Revoke or Rotate the Leaked Key</h2>
<p>Go to the dashboard of the service that issued the credential.</p>
<p>Depending on the provider, you may see options such as:</p>
<ul>
<li><p>Revoke</p>
</li>
<li><p>Delete</p>
</li>
<li><p>Disable</p>
</li>
<li><p>Rotate</p>
</li>
<li><p>Regenerate</p>
</li>
<li><p>Create new key</p>
</li>
</ul>
<p>If the provider supports key rotation, create a replacement credential before disabling the old one if possible. This can reduce application downtime while you update your configuration.</p>
<p>The important thing is that the original credential must no longer be usable.</p>
<p><strong>Do not reuse the leaked key.</strong> Don't rename it. Don't encode it. Don't move it to another file and assume it is safe. Don't assume nobody saw it.</p>
<p>Treat it as compromised.</p>
<h2 id="heading-step-2-investigate-suspicious-activity">Step 2: Investigate Suspicious Activity</h2>
<p>After disabling the credential, check the provider's usage dashboard and logs.</p>
<p>Look for things such as:</p>
<ul>
<li><p>Sudden spikes in requests</p>
</li>
<li><p>Requests from unfamiliar locations</p>
</li>
<li><p>Unexpected database queries</p>
</li>
<li><p>New cloud resources</p>
</li>
<li><p>Changes to permissions</p>
</li>
<li><p>Unexpected downloads</p>
</li>
<li><p>Unusual payment activity</p>
</li>
<li><p>New deployments</p>
</li>
<li><p>Requests at times when your application was inactive</p>
</li>
</ul>
<p>If the credential had broad permissions, assume that anything within its permission scope <strong>MAY have been accessed or modified</strong>.</p>
<p>For example, if a cloud credential could create virtual machines, check whether unexpected machines were created.</p>
<p>If a credential could access a database, review:</p>
<ul>
<li><p>Authentication logs</p>
</li>
<li><p>Read operations</p>
</li>
<li><p>Write operations</p>
</li>
<li><p>Deleted records</p>
</li>
<li><p>Exported data</p>
</li>
<li><p>Newly created accounts</p>
</li>
<li><p>Permission changes</p>
</li>
</ul>
<p>Also check your billing information if the credential could generate usage-based charges.</p>
<p>Write down what you discover. A simple timeline can help:</p>
<pre><code class="language-text">10:15 - API key committed
10:23 - Repository pushed publicly
10:41 - Unusual usage detected
10:45 - Key revoked
11:00 - Logs reviewed
11:30 - Replacement key deployed
12:00 - Git history cleaned
</code></pre>
<p>This can be especially useful if you need to report the incident to a team or service provider.</p>
<h2 id="heading-step-3-remove-the-secret-from-your-current-code">Step 3: Remove the Secret From Your Current Code</h2>
<p>Once the original credential has been disabled, remove it from your working files.</p>
<p>This is unsafe:</p>
<pre><code class="language-javascript">const apiKey = "your-real-api-key";
</code></pre>
<p>Instead, load the credential from the environment:</p>
<pre><code class="language-javascript">const apiKey = process.env.API_KEY;

if (!apiKey) {
  throw new Error("API_KEY is not configured");
}
</code></pre>
<p>In Python:</p>
<pre><code class="language-python">import os

api_key = os.environ.get("API_KEY")

if not api_key:
    raise RuntimeError("API_KEY is not configured")
</code></pre>
<p>The important idea is simple:</p>
<pre><code class="language-text">Source code → environment variable → secret value
</code></pre>
<p>instead of:</p>
<pre><code class="language-text">Source code → hardcoded secret
</code></pre>
<p>Environment variables aren't the only way to manage secrets, but they are a common and practical solution for local development and many deployment environments.</p>
<h2 id="heading-step-4-use-a-env-file-for-local-development">Step 4: Use a <code>.env</code> File for Local Development</h2>
<p>For local development, you can store environment variables in a <code>.env</code> file.</p>
<p>For example:</p>
<pre><code class="language-env">API_KEY=your-local-development-key
DATABASE_URL=your-local-database-url
</code></pre>
<p>A Node.js project can load these values with a package such as <code>dotenv</code>.</p>
<p>Install it with:</p>
<pre><code class="language-bash">npm install dotenv
</code></pre>
<p>Then:</p>
<pre><code class="language-javascript">import "dotenv/config";

const apiKey = process.env.API_KEY;
</code></pre>
<p>The important part is that the <code>.env</code> file normally <strong>should not be committed to Git</strong>.</p>
<p>Add it to <code>.gitignore</code>:</p>
<pre><code class="language-gitignore"># Environment files
.env
.env.*
!.env.example

# Credential files
*.pem
*.key
credentials.json
service-account.json

# Local development files
.DS_Store
</code></pre>
<p>But there's an important detail here: the <code>.gitignore</code> <strong>does NOT remove files that Git is already tracking.</strong></p>
<p>If <code>.env</code> has already been committed, adding it to <code>.gitignore</code> won't erase it from Git.</p>
<p>You can stop tracking the file while keeping it on your computer:</p>
<pre><code class="language-bash">git rm --cached .env
</code></pre>
<p>Then commit the <code>.gitignore</code> change:</p>
<pre><code class="language-bash">git add .gitignore
git commit -m "Ignore local environment files"
</code></pre>
<p>But remember: this only removes the file from future commits. It does <strong>not</strong> remove the secret from previous commits.</p>
<p>That's where Git history comes in.</p>
<h2 id="heading-step-5-create-a-safe-envexample">Step 5: Create a Safe <code>.env.example</code></h2>
<p>Other developers still need to know which environment variables the application requires.</p>
<p>Instead of committing <code>.env</code>, create <code>.env.example</code>:</p>
<pre><code class="language-env">API_KEY=
DATABASE_URL=
PORT=3000
LOG_LEVEL=info
</code></pre>
<p>This file contains variable names rather than real credentials, so it can be committed to the repository.</p>
<p>You can also provide comments:</p>
<pre><code class="language-env"># Required API credential
API_KEY=

# PostgreSQL connection string
DATABASE_URL=

# Optional application port
PORT=3000
</code></pre>
<p>A new developer can then copy the file:</p>
<pre><code class="language-bash">cp .env.example .env
</code></pre>
<p>and provide their own values.</p>
<p>Use clearly fake placeholders in examples:</p>
<pre><code class="language-env">API_KEY=replace-me-with-your-own-key
</code></pre>
<p>Avoid putting realistic-looking production credentials into <code>.env.example</code>.</p>
<h2 id="heading-step-6-determine-whether-the-secret-is-still-in-git-history">Step 6: Determine Whether the Secret Is Still in Git History</h2>
<p>This is one of the most important parts of fixing a leaked credential.</p>
<p>Suppose your Git history looks like this:</p>
<pre><code class="language-text">Commit A: Add API key to config.js
Commit B: Update API integration
Commit C: Delete API key
</code></pre>
<p>Even though Commit C no longer contains the key, Commit A still does.</p>
<p>Git remembers previous versions of your files.</p>
<p>You can inspect the history of a file with:</p>
<pre><code class="language-bash">git log --all -- config.js
</code></pre>
<p>To display a file from an older commit:</p>
<pre><code class="language-bash">git show COMMIT_ID:config.js
</code></pre>
<p>You can also search Git history for a known leaked value:</p>
<pre><code class="language-bash">git log --all -S"your-leaked-key" --oneline
</code></pre>
<p>If you know the secret was committed, you should assume that it exists somewhere in the repository's history until you've verified otherwise.</p>
<h2 id="heading-when-do-you-need-to-rewrite-git-history">When Do You Need to Rewrite Git History?</h2>
<p>Not every accidental secret requires a history rewrite. Consider these situations:</p>
<h3 id="heading-the-secret-was-never-committed">The Secret Was Never Committed</h3>
<p>If the secret exists only in your working directory and was never committed, you generally don't need to rewrite history.</p>
<p>Remove it, add the appropriate file to <code>.gitignore</code>, and continue.</p>
<h3 id="heading-the-secret-was-committed-locally-but-never-pushed">The Secret Was Committed Locally But Never Pushed</h3>
<p>If the secret exists in local commits but hasn't been shared with a remote repository, you may be able to clean up those commits before pushing.</p>
<h3 id="heading-the-secret-was-pushed-to-a-remote-repository">The Secret Was Pushed to a Remote Repository</h3>
<p>Treat the credential as compromised. Revoke or rotate it immediately.</p>
<p>Then determine whether removing the secret from the repository's history is appropriate.</p>
<h3 id="heading-the-repository-was-public">The Repository Was Public</h3>
<p>Assume that someone or something may already have copied the secret.</p>
<p>This is why <strong>revocation comes before Git cleanup</strong>.</p>
<h3 id="heading-the-secret-was-in-a-private-repository">The Secret Was in a Private Repository</h3>
<p>A private repository is safer than a public repository, but it isn't a secret vault.</p>
<p>Credentials can still escape through:</p>
<ul>
<li><p>Compromised accounts</p>
</li>
<li><p>Contractors</p>
</li>
<li><p>Integrations</p>
</li>
<li><p>CI logs</p>
</li>
<li><p>Forks</p>
</li>
<li><p>Backups</p>
</li>
<li><p>Screenshots</p>
</li>
<li><p>Copied code</p>
</li>
<li><p>Pull requests</p>
</li>
</ul>
<p>So the safest rule remains:</p>
<blockquote>
<p><strong>Never intentionally commit credentials to Git, even in a private repository.</strong></p>
</blockquote>
<h2 id="heading-step-7-remove-the-secret-from-git-history">Step 7: Remove the Secret From Git History</h2>
<p>If the credential was committed, you may need to remove it from the repository's history.</p>
<p>Before rewriting history, create a backup:</p>
<pre><code class="language-bash">git clone --mirror https://github.com/your-username/your-repository.git repository-backup.git
</code></pre>
<p>A mirror clone includes branches and tags, which makes it useful for recovery if something goes wrong.</p>
<h3 id="heading-option-1-remove-an-entire-file">Option 1: Remove an Entire File</h3>
<p>If the secret was stored in a file such as <code>.env</code>, you can remove that file from the entire history:</p>
<pre><code class="language-bash">git filter-repo --path .env --invert-paths
</code></pre>
<p>For a file inside a directory:</p>
<pre><code class="language-bash">git filter-repo --path config/production.json --invert-paths
</code></pre>
<p>This removes the file from the repository's rewritten history.</p>
<h3 id="heading-option-2-replace-a-secret-inside-a-file">Option 2: Replace a Secret Inside a File</h3>
<p>Sometimes you need to keep the file but remove the secret from previous versions.</p>
<p>Create a temporary replacements file:</p>
<p>Then run:</p>
<pre><code class="language-bash">git filter-repo --replace-text replacements.txt
</code></pre>
<p>You can replace the value with a placeholder:</p>
<pre><code class="language-text">your-leaked-key==&gt;YOUR_API_KEY_HERE
</code></pre>
<p>Be extremely careful with <code>replacements.txt</code>. It contains the original secret, so <strong>do not commit it.</strong></p>
<p>Delete it after the cleanup:</p>
<pre><code class="language-bash">rm replacements.txt
</code></pre>
<p>On Windows PowerShell:</p>
<pre><code class="language-powershell">Remove-Item replacements.txt
</code></pre>
<p>For multiple secrets:</p>
<pre><code class="language-text">old-api-key==&gt;REMOVED_API_KEY
old-database-password==&gt;REMOVED_DATABASE_PASSWORD
old-token==&gt;REMOVED_TOKEN
</code></pre>
<p>Then:</p>
<pre><code class="language-bash">git filter-repo --replace-text replacements.txt
</code></pre>
<p>Test the cleanup on your backup clone first.</p>
<h2 id="heading-step-8-verify-that-the-secret-is-gone">Step 8: Verify That the Secret Is Gone</h2>
<p>Never assume the cleanup worked just because the command completed successfully.</p>
<p>Search for the known leaked value again:</p>
<pre><code class="language-bash">git log --all -S"your-leaked-key" --oneline
</code></pre>
<p>You can also inspect relevant files and commits:</p>
<pre><code class="language-bash">git log --all -- config.js
</code></pre>
<p>and:</p>
<pre><code class="language-bash">git show COMMIT_ID:config.js
</code></pre>
<p>If your repository uses branches and tags, make sure you aren't checking only the branch you currently have checked out.</p>
<p>You should also inspect other locations where the secret may have appeared, including pull requests, CI/CD logs, build artifacts, release files, Docker images, package releases, documentation, issue comments, and screenshots</p>
<p>Remember:</p>
<blockquote>
<p><strong>Rewriting your repository doesn't erase copies that already exist somewhere else.</strong></p>
</blockquote>
<p>That's another reason why the original credential must be revoked.</p>
<h2 id="heading-step-9-push-the-cleaned-history-carefully">Step 9: Push the Cleaned History Carefully</h2>
<p>Once you've verified the cleanup, you may need to push the rewritten history:</p>
<pre><code class="language-bash">git push --force --all origin
git push --force --tags origin
</code></pre>
<h3 id="heading-important-warning">Important Warning</h3>
<p><strong>Force-pushing rewritten history is disruptive.</strong> It changes commit hashes and can affect collaborators who have existing clones of the repository.</p>
<p>Before doing this on a shared project:</p>
<ol>
<li><p>Tell your collaborators.</p>
</li>
<li><p>Make sure everyone understands that history is being rewritten.</p>
</li>
<li><p>Coordinate the cleanup.</p>
</li>
<li><p>Follow your organization's incident-response process if one exists.</p>
</li>
</ol>
<p>After the rewrite, collaborators may need to reclone the repository:</p>
<pre><code class="language-bash">git clone https://github.com/your-username/your-repository.git
</code></pre>
<p>They shouldn't blindly merge their old repository history back into the cleaned repository.</p>
<h2 id="heading-step-10-replace-the-credential-everywhere">Step 10: Replace the Credential Everywhere</h2>
<p>Now create or use the replacement credential. Update every environment where the application runs. Common locations include:</p>
<ul>
<li><p>Local development</p>
</li>
<li><p>Testing</p>
</li>
<li><p>Staging</p>
</li>
<li><p>Production</p>
</li>
<li><p>Docker containers</p>
</li>
<li><p>Kubernetes secrets</p>
</li>
<li><p>CI/CD systems</p>
</li>
<li><p>Hosting platforms</p>
</li>
<li><p>Scheduled jobs</p>
</li>
<li><p>Serverless functions</p>
</li>
</ul>
<p>A common mistake is updating production but forgetting the deployment pipeline.</p>
<p>For example, your local application may work because <code>.env</code> contains the new key, while your CI/CD system still contains the old one.</p>
<p>Make a checklist:</p>
<pre><code class="language-text">1. Local development
2. Automated tests
3. Staging
4. Production
5. CI/CD variables
6. Docker configuration
7. Cloud deployment settings
8. Scheduled scripts
9. Serverless functions
</code></pre>
<p>After updating the credential, test the application in each important environment.</p>
<h2 id="heading-step-11-restrict-the-replacement-key">Step 11: Restrict the Replacement Key</h2>
<p>Replacing a leaked credential is only part of the solution.</p>
<p>The new credential should have <strong>only the permissions it actually needs</strong>.</p>
<p>Useful restrictions can include:</p>
<ul>
<li><p>Read-only permissions</p>
</li>
<li><p>Specific API scopes</p>
</li>
<li><p>Allowed IP addresses</p>
</li>
<li><p>Allowed domains</p>
</li>
<li><p>Environment-specific access</p>
</li>
<li><p>Request quotas</p>
</li>
<li><p>Rate limits</p>
</li>
<li><p>Expiration dates</p>
</li>
</ul>
<p>For example, a weather application may only need permission to read weather data.</p>
<p>It shouldn't have permission to manage users, modify billing, or delete unrelated resources.</p>
<p>This is the <strong>principle of least privilege</strong>:</p>
<blockquote>
<p><strong>Give each credential the smallest amount of access necessary to perform its job.</strong></p>
</blockquote>
<p>It's also a good idea to use different credentials for different environments:</p>
<pre><code class="language-text">local-development-key
testing-key
staging-key
production-key
</code></pre>
<p>That way, a development credential leak doesn't automatically expose production resources.</p>
<h2 id="heading-what-about-frontend-applications">What About Frontend Applications?</h2>
<p>This is where API-key security gets confusing.</p>
<p>Frontend code runs on the user's device.</p>
<p>That means users can inspect it.</p>
<p>For example:</p>
<pre><code class="language-javascript">const apiKey = "browser-key";
</code></pre>
<p>A user can inspect the JavaScript bundle, browser developer tools, or network requests and potentially see the value.</p>
<p>Some services intentionally provide browser API keys that are designed to be publicly visible.</p>
<p>Those keys should still be restricted by things such as:</p>
<ul>
<li><p>Allowed domains</p>
</li>
<li><p>Website origins</p>
</li>
<li><p>API operations</p>
</li>
<li><p>Usage quotas</p>
</li>
<li><p>Referrer restrictions</p>
</li>
<li><p>Time limits</p>
</li>
</ul>
<p>But a truly private credential should <strong>never be placed in browser code</strong>.</p>
<p>Instead of:</p>
<pre><code class="language-javascript">fetch("https://private-api.example.com/data", {
  headers: {
    Authorization: "Bearer private-secret-token"
  }
});
</code></pre>
<p>have the browser call your own backend:</p>
<pre><code class="language-javascript">fetch("/api/data");
</code></pre>
<p>Then the backend communicates with the private service:</p>
<pre><code class="language-javascript">const response = await fetch(
  "https://private-api.example.com/data",
  {
    headers: {
      Authorization: `Bearer ${process.env.PRIVATE_API_TOKEN}`
    }
  }
);
</code></pre>
<p>The backend can then return only the information the browser is allowed to receive.</p>
<p>The important distinction is:</p>
<pre><code class="language-text">Public/browser credential
        ↓
Can be visible, but should be restricted

Private credential
        ↓
Must remain on a trusted backend or secret-management system
</code></pre>
<h2 id="heading-environment-variables-vs-secret-managers">Environment Variables vs Secret Managers</h2>
<p>Environment variables are useful, but they're not a universal secret-management solution.</p>
<p>For a small application or local development environment, something like:</p>
<pre><code class="language-env">API_KEY=your-secret
</code></pre>
<p>may be perfectly reasonable.</p>
<p>For larger production systems, you may want a dedicated <strong>secret manager</strong>.</p>
<p>A secret-management system can provide features such as:</p>
<ul>
<li><p>Centralized credential storage</p>
</li>
<li><p>Access controls</p>
</li>
<li><p>Auditing</p>
</li>
<li><p>Credential rotation</p>
</li>
<li><p>Versioning</p>
</li>
<li><p>Separation between environments</p>
</li>
<li><p>Integration with deployment systems</p>
</li>
</ul>
<p>The important idea is that your source code shouldn't be responsible for storing production secrets.</p>
<p>Instead:</p>
<pre><code class="language-text">Application
    ↓
Secret management system
    ↓
Credential
</code></pre>
<p>rather than:</p>
<pre><code class="language-text">Application
    ↓
Hardcoded production credential
</code></pre>
<p>Which solution you use depends on the size and requirements of your project.</p>
<h2 id="heading-add-secret-scanning-to-your-workflow">Add Secret Scanning to Your Workflow</h2>
<p>Humans are excellent programmers and occasionally terrible search engines.</p>
<p>Automated secret scanning can catch credentials before they make it into a repository.</p>
<p>Popular tools include:</p>
<ul>
<li><p>Gitleaks</p>
</li>
<li><p>TruffleHog</p>
</li>
<li><p>detect-secrets</p>
</li>
<li><p>Pre-commit hooks</p>
</li>
<li><p>Git hosting secret scanning</p>
</li>
<li><p>CI security scanners</p>
</li>
</ul>
<p>For example, you can run Gitleaks locally:</p>
<pre><code class="language-bash">gitleaks detect --source . --verbose
</code></pre>
<p>You can also integrate secret scanning into CI.</p>
<p>A basic GitHub Actions workflow might look like this:</p>
<pre><code class="language-yaml">name: Secret Scan

on:
  push:
  pull_request:

jobs:
  scan:
    runs-on: ubuntu-latest

    steps:
      - name: Check out repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Scan for secrets
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
</code></pre>
<p>Review the documentation for your chosen tool and pin versions according to your project's security practices.</p>
<p>Secret scanners can produce false positives, so you may need to configure exceptions for safe test values.</p>
<p>Be careful with allowlists, though. An overly broad exception can hide a real credential.</p>
<h2 id="heading-use-git-hooks-as-an-extra-safety-net">Use Git Hooks as an Extra Safety Net</h2>
<p>You can also scan files before they're committed.</p>
<p>For example, a simple pre-commit script could search for suspicious words:</p>
<pre><code class="language-bash">#!/usr/bin/env bash

if grep -RniE "api[_-]?key|password|secret|token|private[_-]?key" . \
  --exclude-dir=.git \
  --exclude=".env.example"; then

  echo "Possible secret detected. Commit cancelled."
  exit 1
fi
</code></pre>
<p>This isn't a complete security scanner, but it can catch obvious mistakes.</p>
<p>For stronger protection, use a dedicated secret-scanning tool through a pre-commit framework.</p>
<p>The goal isn't to make committing miserable. The goal is to make accidentally publishing a credential harder.</p>
<h2 id="heading-review-your-staged-diff-before-committing">Review Your Staged Diff Before Committing</h2>
<p>One of the simplest security habits you can develop is checking what you're actually about to commit.</p>
<p>First:</p>
<pre><code class="language-bash">git status
</code></pre>
<p>Then stage only the files you intend to commit:</p>
<pre><code class="language-bash">git add src/api.js README.md
</code></pre>
<p>Now inspect the staged changes:</p>
<pre><code class="language-bash">git diff --cached
</code></pre>
<p>Look for:</p>
<ul>
<li><p>API keys</p>
</li>
<li><p>Passwords</p>
</li>
<li><p>Tokens</p>
</li>
<li><p>Private URLs</p>
</li>
<li><p>Internal hostnames</p>
</li>
<li><p>Customer data</p>
</li>
<li><p>Debug output</p>
</li>
<li><p>Personal information</p>
</li>
<li><p>Private certificates</p>
</li>
</ul>
<p>Only commit after the staged diff looks correct:</p>
<pre><code class="language-bash">git commit -m "Load API key from environment"
</code></pre>
<p>Be cautious with:</p>
<pre><code class="language-bash">git add .
</code></pre>
<p>It can stage files you never intended to publish, including <code>.env</code> files, database exports, generated files, or local configuration.</p>
<h2 id="heading-common-mistakes-developers-make">Common Mistakes Developers Make</h2>
<h3 id="heading-mistake-1-i-deleted-it-so-its-fine">Mistake 1: "I Deleted It, So It's Fine"</h3>
<p>Deleting a secret from the current version of a file doesn't delete it from Git history.</p>
<p><strong>Correct response:</strong> Revoke the credential and clean the repository history when appropriate.</p>
<h3 id="heading-mistake-2-the-repository-is-private">Mistake 2: "The Repository Is Private"</h3>
<p>Private repositories aren't vaults.</p>
<p>Credentials can still escape through compromised accounts, integrations, CI logs, forks, backups, or copied code.</p>
<p><strong>Correct response:</strong> Don't commit secrets even to private repositories.</p>
<h3 id="heading-mistake-3-ill-just-encode-it">Mistake 3: "I'll Just Encode It"</h3>
<p>These don't make a credential secret:</p>
<pre><code class="language-javascript">const key = atob("c29tZS1rZXk=");
</code></pre>
<p>or:</p>
<pre><code class="language-javascript">const key = "some-" + "secret-" + "value";
</code></pre>
<p>Encoding, splitting, renaming, or hiding a credential doesn't protect it.</p>
<p>If your application can reconstruct the credential, someone analyzing the application may be able to do the same.</p>
<h3 id="heading-mistake-4-logging-the-secret">Mistake 4: Logging the Secret</h3>
<p>Don't do this:</p>
<pre><code class="language-javascript">console.log(process.env.API_KEY);
</code></pre>
<p>Logs can be stored by your terminal, CI system, hosting provider, monitoring platform, or cloud service.</p>
<p>Instead:</p>
<pre><code class="language-javascript">console.log(
  "API key configured:",
  Boolean(process.env.API_KEY)
);
</code></pre>
<p>If you absolutely need to inspect a value during debugging, avoid printing the full credential.</p>
<p>For example:</p>
<pre><code class="language-javascript">function maskSecret(value) {
  if (!value) return "not configured";
  if (value.length &lt;= 8) return "********";

  return `${value.slice(0, 4)}...${value.slice(-4)}`;
}

console.log(maskSecret(process.env.API_KEY));
</code></pre>
<p>Even masked credentials should be handled carefully.</p>
<h3 id="heading-mistake-5-using-the-same-credential-everywhere">Mistake 5: Using the Same Credential Everywhere</h3>
<p>If local development, testing, staging, and production all use the same credential, one leak can affect everything.</p>
<p><strong>Correct response:</strong> Use separate credentials with separate permissions.</p>
<h3 id="heading-mistake-6-cleaning-only-the-current-branch">Mistake 6: Cleaning Only the Current Branch</h3>
<p>A secret can remain in:</p>
<ul>
<li><p>Old branches</p>
</li>
<li><p>Tags</p>
</li>
<li><p>Pull requests</p>
</li>
<li><p>Other references</p>
</li>
</ul>
<p><strong>Correct response:</strong> Consider the entire repository when investigating and cleaning a leaked credential.</p>
<h3 id="heading-mistake-7-forgetting-build-artifacts">Mistake 7: Forgetting Build Artifacts</h3>
<p>A secret might also appear in:</p>
<ul>
<li><p>Compiled JavaScript bundles</p>
</li>
<li><p>Docker images</p>
</li>
<li><p>Downloadable releases</p>
</li>
<li><p>Published packages</p>
</li>
<li><p>Generated documentation</p>
</li>
</ul>
<p><strong>Correct response:</strong> Revoke the credential and identify affected artifacts that may need to be removed or replaced.</p>
<h2 id="heading-a-complete-api-key-incident-checklist">A Complete API-Key Incident Checklist</h2>
<p>If you discover that you've exposed an API key, use this checklist:</p>
<pre><code class="language-text">1. Revoke or rotate the leaked key
2. Create a replacement credential
3. Restrict the replacement credential
4. Review provider logs
5. Review billing and usage
6. Check for unauthorized resources
7. Remove the key from current files
8. Add secret files to .gitignore
9. Create or update .env.example
10. Search Git history
11. Check branches and tags
12. Remove the secret from Git history if necessary
13. Verify the old secret is gone
14. Force-push cleaned history if appropriate
15. Check pull requests and forks
16. Check CI and deployment logs
17. Update local configuration
18. Update staging configuration
19. Update production configuration
20. Update CI/CD secrets
21. Run a secret scanner
22. Document the incident
23. Add preventive security checks
</code></pre>
<p>The exact steps will depend on your provider and project, but the order matters: <strong>Invalidate first. Clean up second.</strong></p>
<h2 id="heading-a-secure-project-structure">A Secure Project Structure</h2>
<p>A simple Node.js project might look like this:</p>
<pre><code class="language-text">my-project/
├── src/
│   └── api.js
├── .env
├── .env.example
├── .gitignore
├── package.json
└── README.md
</code></pre>
<p>The local <code>.env</code> file contains the actual development value:</p>
<pre><code class="language-env">API_KEY=your-local-key
</code></pre>
<p>The <code>.env.example</code> file contains no real credential:</p>
<pre><code class="language-env">API_KEY=replace-me-with-your-own-key
</code></pre>
<p>The application reads the environment variable:</p>
<pre><code class="language-javascript">import "dotenv/config";

const apiKey = process.env.API_KEY;

if (!apiKey) {
  throw new Error("Missing API_KEY environment variable");
}

export async function getData() {
  const response = await fetch(
    "https://api.example.com/data",
    {
      headers: {
        Authorization: `Bearer ${apiKey}`
      }
    }
  );

  if (!response.ok) {
    throw new Error(
      `API request failed: ${response.status}`
    );
  }

  return response.json();
}
</code></pre>
<p>And <code>.gitignore</code> keeps the local environment file out of future commits:</p>
<pre><code class="language-gitignore">.env
.env.*
!.env.example

node_modules/
</code></pre>
<p>Finally, your README can explain the setup without exposing credentials:</p>
<p>Step 1: Copy the example environment file on your bash <code>cp .env.example .env</code></p>
<p>Step 2: Add your own API key to <code>.env</code>.</p>
<p>And last but not least, start your application!</p>
<pre><code class="language-bash">npm start
</code></pre>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>Leaking an API key doesn't mean you're a terrible developer. It just means your development workflow needs better guardrails.</p>
<p>The important thing is knowing how to respond quickly and how to prevent the same mistake from happening again.</p>
<p>Remember the emergency formula:</p>
<pre><code class="language-text">Invalidate → Investigate → Remove → Replace → Prevent
</code></pre>
<p>The important thing is knowing how to respond quickly and how to prevent the same mistake from happening again.</p>
<ul>
<li><p>Invalidate the leaked credential so it can no longer be used.</p>
</li>
<li><p>Investigate your logs, usage, and billing to determine whether it was abused.</p>
</li>
<li><p>Remove the secret from your current code and, when necessary, from Git history.</p>
</li>
<li><p>Replace it with a new credential that has only the permissions it needs.</p>
</li>
<li><p>Prevent future leaks with environment variables, secret managers, secret scanning, and careful Git practices.</p>
</li>
</ul>
<p>Git is excellent at remembering your project's history. That's useful when you accidentally delete an important function. But it's much less useful when that history contains a password.</p>
<p>So keep your code public when appropriate. And <strong>keep your secrets somewhere else.</strong></p>
<p>Happy coding!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Type-Safe APIs with Hono and Zod ]]>
                </title>
                <description>
                    <![CDATA[ If you've shipped a Node.js API before, you already know this pain: your TypeScript types say one thing, your runtime validation says another, and your OpenAPI docs quietly disagree with both. Someone ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-type-safe-apis-with-hono-and-zod/</link>
                <guid isPermaLink="false">6a8c4f18dbeb4eb3a4b2ed0d</guid>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ hono ]]>
                    </category>
                
                    <category>
                        <![CDATA[ zod ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ backend ]]>
                    </category>
                
                    <category>
                        <![CDATA[ webdev ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chinedu Otutu ]]>
                </dc:creator>
                <pubDate>Mon, 24 Aug 2026 14:03:04 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ce251811-43ad-4e2f-9761-83b6240f718e.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've shipped a Node.js API before, you already know this pain: your TypeScript types say one thing, your runtime validation says another, and your OpenAPI docs quietly disagree with both.</p>
<p>Someone adds a field to an interface, and the schema never gets updated. The docs stay stale until a client files a bug. There's no compiler error or failing test. The three sources of truth just drift apart.</p>
<p>In this tutorial, you'll learn how to collapse those three concerns into one definition using <a href="https://hono.dev">Hono</a> and <a href="https://zod.dev">Zod</a>. You'll build the same patterns I use in production, including in <a href="https://github.com/otutukingsley/clipforge">ClipForge</a>, an open-source video processing toolkit I built and maintain. They help validation, types, and docs stay in sync by design.</p>
<p>By the end of this article, you should know how to:</p>
<ul>
<li><p>Define one Zod schema that handles runtime validation, TypeScript types, and OpenAPI docs</p>
</li>
<li><p>Structure routes so the contract and the handler stay separated</p>
</li>
<li><p>Keep database schemas and HTTP schemas as two deliberate layers</p>
</li>
<li><p>Return one consistent error shape from every failure path</p>
</li>
<li><p>Carry those same patterns from a small Tasks API into a real multi-service system</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To get the most out of this article, you'll need to know:</p>
<ul>
<li><p>JavaScript and basic TypeScript</p>
</li>
<li><p>How REST APIs work (routes, request bodies, status codes)</p>
</li>
<li><p>A little Node.js (installing packages, running scripts)</p>
</li>
</ul>
<p>You don't need prior experience with Hono, Zod, or Drizzle.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-1-the-drift-problem">1. The Drift Problem</a></p>
</li>
<li><p><a href="#heading-2-what-is-hono">2. What Is Hono?</a></p>
</li>
<li><p><a href="#heading-3-what-is-zod">3. What Is Zod?</a></p>
</li>
<li><p><a href="#heading-4-one-schema-three-jobs">4. One Schema, Three Jobs</a></p>
</li>
<li><p><a href="#heading-5-how-to-set-up-the-project">5. How to Set Up the Project</a></p>
</li>
<li><p><a href="#heading-6-how-to-define-your-api-schemas">6. How to Define Your API Schemas</a></p>
</li>
<li><p><a href="#heading-7-how-to-separate-database-schemas-from-api-schemas">7. How to Separate Database Schemas from API Schemas</a></p>
</li>
<li><p><a href="#heading-8-how-to-define-routes-as-contracts">8. How to Define Routes as Contracts</a></p>
</li>
<li><p><a href="#heading-9-how-to-keep-handlers-thin">9. How to Keep Handlers Thin</a></p>
</li>
<li><p><a href="#heading-10-how-to-return-one-error-shape-everywhere">10. How to Return One Error Shape Everywhere</a></p>
</li>
<li><p><a href="#heading-11-how-to-generate-docs-that-cant-drift">11. How to Generate Docs That Can't Drift</a></p>
</li>
<li><p><a href="#heading-12-how-to-make-the-app-production-ready">12. How to Make the App Production-Ready</a></p>
</li>
<li><p><a href="#heading-13-how-these-patterns-scale-in-a-production-app">13. How These Patterns Scale in a Production App</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-1-the-drift-problem">1. The Drift Problem</h2>
<p>Most TypeScript APIs end up maintaining three separate descriptions of the same data:</p>
<ol>
<li><p><strong>Runtime validation</strong> checks that run when a request arrives</p>
</li>
<li><p><strong>TypeScript types</strong> <strong>shapes</strong> the compiler understands at build time</p>
</li>
<li><p><strong>API documentation</strong>, the contract you show to consumers</p>
</li>
</ol>
<p>Each one lives in a different file and updates on a different schedule. And none of them can see the others.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/1b34f0ca-6b0f-48c0-9282-7bc07d99f171.png" alt="Three API sources of truth often drifting out of sync: TypeScript types, runtime validation, and OpenAPI docs" style="display: block;" width="1100" height="520" loading="lazy">

<p><em>Figure 1: Three descriptions of the same data, maintained separately and often out of sync.</em></p>
<p>Hand-written interfaces disappear at runtime. Joi or Yup schemas validate data but don't give you types for free. OpenAPI files are usually edited by hand, if they're edited at all.</p>
<p>The fix is not "be more careful." The fix is one definition that produces all three outputs.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/df2d559a-6afb-469d-81cb-363ab54d0dba.png" alt="One Zod schema producing runtime validation, TypeScript types, and OpenAPI docs" style="display: block;" width="1100" height="480" loading="lazy">

<p><em>Figure 2: One schema definition produces three outputs.</em></p>
<p>That is what Hono and Zod give you when you use them together through <code>@hono/zod-openapi</code>.</p>
<h2 id="heading-2-what-is-hono">2. What Is Hono?</h2>
<p><a href="https://hono.dev">Hono</a> is a small, fast web framework built on Web Standard APIs, the same <code>Request</code> and <code>Response</code> primitives that run in Node.js, Deno, Bun, and Cloudflare Workers.</p>
<p>Compared with Express, that difference matters:</p>
<table>
<thead>
<tr>
<th>Express</th>
<th>Hono</th>
</tr>
</thead>
<tbody><tr>
<td>Node-specific <code>req</code> / <code>res</code></td>
<td>Web Standard APIs</td>
</tr>
<tr>
<td>Params are untyped strings</td>
<td>Params validated and typed with Zod</td>
</tr>
<tr>
<td>Validation is your problem</td>
<td>Route definition is the OpenAPI entry</td>
</tr>
<tr>
<td>Runs on Node only</td>
<td>Runs on Node, Bun, Deno, and the edge</td>
</tr>
</tbody></table>
<p>Hono's core is about 14kb. On Node it's roughly 5–7× faster than Express for the same workload. On Bun or Cloudflare Workers, the gap widens further because those runtimes are optimized for web standards.</p>
<p>For most CRUD APIs, your database is still the bottleneck. But at high concurrency, or on edge runtimes where cold starts matter, the framework gap is real.</p>
<p>More importantly for this tutorial: Hono's OpenAPI integration lets your route definition <em>be</em> the documentation.</p>
<h2 id="heading-3-what-is-zod">3. What Is Zod?</h2>
<p><a href="https://zod.dev">Zod</a> is a TypeScript-first schema validation library. You describe the shape of your data once. Zod then:</p>
<ol>
<li><p>Validates that shape at runtime</p>
</li>
<li><p>Infers the TypeScript type with <code>z.infer</code></p>
</li>
<li><p>Feeds OpenAPI docs when you attach <code>.openapi()</code> metadata</p>
</li>
</ol>
<p>With Joi or Yup, you usually validate at runtime and then hand-write a matching interface. That's two definitions again. Zod removes the second one.</p>
<pre><code class="language-typescript">import { z } from 'zod';

const createTaskSchema = z.object({
  title: z.string().min(1).max(120),
  status: z.enum(['todo', 'in_progress', 'done']).default('todo'),
});

type CreateTaskInput = z.infer&lt;typeof createTaskSchema&gt;;
// { title: string; status?: "todo" | "in_progress" | "done" }
</code></pre>
<p>Change the schema, and every callsite that depends on <code>CreateTaskInput</code> updates with it. TypeScript will tell you what broke.</p>
<h2 id="heading-4-one-schema-three-jobs">4. One Schema, Three Jobs</h2>
<p>Here's the mental model for the rest of the article:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/890719d0-6ab0-4529-ad07-565a7db9e604.png" alt="taskSchema feeding runtime validation, TypeScript types, and OpenAPI documentation" style="display: block;" width="1200" height="500" loading="lazy">

<p><em>Figure 3:</em> <code>taskSchema</code> <em>is the single source of truth for validation, types, and docs.</em></p>
<ol>
<li><p><strong>Runtime validation:</strong> bad data is rejected before it reaches your handler, with structured field errors instead of a stack trace</p>
</li>
<li><p><strong>TypeScript types:</strong> <code>z.infer&lt;typeof schema&gt;</code> is derived from the schema, not maintained beside it</p>
</li>
<li><p><strong>OpenAPI docs:</strong> <code>.openapi('Name')</code> registers the schema in the generated spec, so <code>/reference</code> stays current</p>
</li>
</ol>
<p>One definition with three outputs. Nothing to keep in sync by hand.</p>
<h2 id="heading-5-how-to-set-up-the-project">5. How to Set Up the Project</h2>
<p>We'll use the companion demo from <a href="https://github.com/otutukingsley/api-conf-demo">api-conf-demo</a>. It's a small Tasks API that shows the patterns cleanly. Later, we'll look at how the same ideas show up in ClipForge at a larger scale.</p>
<p>Clone the repo and install dependencies:</p>
<pre><code class="language-bash">git clone https://github.com/otutukingsley/api-conf-demo.git
cd api-conf-demo
npm install
</code></pre>
<p>Start the server:</p>
<pre><code class="language-bash">npm run dev
</code></pre>
<p>You should see the API on <code>http://localhost:8080</code>, with interactive docs at <code>/reference</code>.</p>
<p>The important folders look like this:</p>
<pre><code class="language-text">src/
├── db/schema/          # Persistence layer (Drizzle tables)
├── lib/schemas/        # HTTP contract layer (Zod + OpenAPI)
├── lib/errors/         # One error envelope for every failure
├── routes/tasks/       # Route contracts + handlers
├── services/           # Business logic and DB access
├── app.ts              # Middleware, routers, OpenAPI wiring
└── env.ts              # Zod-validated environment config
</code></pre>
<p>This is still MVC. The tooling is just better:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/bd701dc8-6f83-490a-905d-fbba09438154.png" alt="MVC-style layers with HTTP client, controller, contract schemas, service model, and database" style="display: block;" width="900" height="720" loading="lazy">

<p><em>Figure 4: Still MVC. Routes and handlers as the controller, schemas as the contract, and services as the model.</em></p>
<ul>
<li><p><strong>Model</strong> services its own business logic and database access</p>
</li>
<li><p><strong>View / contract</strong> schemas define what data looks like at the DB and HTTP boundaries</p>
</li>
<li><p><strong>Controller</strong> routes declare the contract, handlers fulfill it</p>
</li>
</ul>
<p>A request through the demo API looks like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/5019c39c-8c45-46f4-8e68-b6388e4c9944.png" alt="Sequence diagram of a POST /tasks request through middleware, route contract, handler, service, and database" style="display: block;" width="1300" height="620" loading="lazy">

<p><em>Figure 5: Request flow through the demo API, including the validation failure path.</em></p>
<p>In that diagram, a request flows through the Client, Middleware, Route contract, Handler, Service, and DB. Middleware handles logging and CORS. The route contract validates the body with Zod.</p>
<p>If validation fails, the client gets a <code>422 ApiError</code> and the handler never runs. If it passes, the handler gets a typed <code>CreateTaskInput</code>, calls <code>TaskService.create()</code>, the service inserts and parses the row, and the client gets <code>201</code> with the Task JSON.</p>
<h2 id="heading-6-how-to-define-your-api-schemas">6. How to Define Your API Schemas</h2>
<p>Start with the HTTP contract in <code>src/lib/schemas/task.ts</code>:</p>
<pre><code class="language-typescript">import { z } from '@hono/zod-openapi';

export const taskStatusSchema = z
  .enum(['todo', 'in_progress', 'done'])
  .openapi('TaskStatus');

export const taskSchema = z
  .object({
    id: z.string().uuid().openapi({
      example: '8e2c9f0a-2222-4a5a-9c3e-1a2b3c4d5e6f',
    }),
    title: z.string().min(1).max(120).openapi({
      example: 'Write the talk abstract',
    }),
    description: z.string().max(2000).nullable().openapi({
      example: 'Cover Hono + Zod patterns',
    }),
    status: taskStatusSchema.default('todo'),
    dueDate: z.string().date().nullable().openapi({
      example: '2026-07-15',
    }),
    createdAt: z.string().openapi({ example: '2026-06-28 10:15:00' }),
    updatedAt: z.string().openapi({ example: '2026-06-28 10:15:00' }),
  })
  .openapi('Task');

export type Task = z.infer&lt;typeof taskSchema&gt;;
</code></pre>
<p>That one object is now the runtime validator, TypeScript type, and an OpenAPI component named <code>Task</code>.</p>
<p>Request schemas should derive from the same base instead of being redeclared:</p>
<pre><code class="language-typescript">export const createTaskSchema = taskSchema
  .pick({ title: true, description: true, status: true, dueDate: true })
  .partial({ description: true, status: true, dueDate: true })
  .openapi('CreateTask');

export const updateTaskSchema = createTaskSchema
  .partial()
  .openapi('UpdateTask');

export type CreateTaskInput = z.infer&lt;typeof createTaskSchema&gt;;
export type UpdateTaskInput = z.infer&lt;typeof updateTaskSchema&gt;;
</code></pre>
<p><code>CreateTask</code> never includes <code>id</code>, because clients don't send one. <code>UpdateTask</code> makes every field optional, because a <code>PATCH</code> can touch any subset.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/2f7cd7b3-1f40-4102-83f9-2c682eecc301.png" alt="taskSchema deriving createTaskSchema and updateTaskSchema" style="display: block;" width="780" height="620" loading="lazy">

<p><em>Figure 6: Request schemas derive from the same base resource schema.</em></p>
<p>We have three contracts with one source.</p>
<h2 id="heading-7-how-to-separate-database-schemas-from-api-schemas">7. How to Separate Database Schemas from API Schemas</h2>
<p>It's tempting to treat the database row and the API response as the same shape. In a tiny demo, they often look identical. In production, they diverge.</p>
<p>Keep them in separate files on purpose:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/7a80d075-4f35-467a-8470-3d87448a5982.png" alt="Database persistence schemas mapped to HTTP contract schemas in the service layer" style="display: block;" width="1200" height="520" loading="lazy">

<p><em>Figure 7: Keep database schemas and HTTP schemas as two deliberate layers.</em></p>
<p>Figure 7 has two panels connected by an arrow labeled "map" in the service.</p>
<p>Left pane <code>src/db/schema/</code> (persistence):</p>
<ul>
<li><p>Starts from a Drizzle table definition</p>
</li>
<li><p>That table drives SQL migrations</p>
</li>
<li><p>It also produces drizzle-zod schemas for parsing rows</p>
</li>
<li><p>And row insert/select types for TypeScript at the database boundary</p>
</li>
</ul>
<p>Right panel <code>src/lib/schemas/</code> (HTTP contract):</p>
<ul>
<li><p>Uses Zod + <code>.openapi()</code> as the public contract</p>
</li>
<li><p>Defines request bodies / params clients may send</p>
</li>
<li><p>Defines response bodies clients receive</p>
</li>
<li><p>Registers OpenAPI components used by <code>/doc</code> and <code>/reference</code></p>
</li>
</ul>
<p>The arrow in the middle matters: the database shape isn't automatically the API shape. The <strong>service</strong> maps between them. That's why an internal column can exist on the left without becoming part of the HTTP contract on the right.</p>
<p>In short:</p>
<ul>
<li><p><code>src/db/schema/</code>: what a row looks like in the database</p>
</li>
<li><p><code>src/lib/schemas/</code>: what the HTTP contract looks like</p>
</li>
</ul>
<p>Here's the Drizzle table from the demo:</p>
<pre><code class="language-typescript">import { sql } from 'drizzle-orm';
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { z } from 'zod';

export const taskStatusValues = ['todo', 'in_progress', 'done'] as const;

export const tasks = sqliteTable('tasks', {
  id: text('id').primaryKey(),
  title: text('title').notNull(),
  description: text('description'),
  status: text('status', { enum: taskStatusValues }).notNull().default('todo'),
  dueDate: text('due_date'),
  createdAt: text('created_at')
    .notNull()
    .default(sql`(current_timestamp)`),
  updatedAt: text('updated_at')
    .notNull()
    .default(sql`(current_timestamp)`),
});

export const selectTaskSchema = createSelectSchema(tasks, {
  status: z.enum(taskStatusValues),
});

export const insertTaskSchema = createInsertSchema(tasks, {
  id: () =&gt; z.string().uuid().optional(),
  title: () =&gt; z.string().min(1).max(120),
  description: () =&gt; z.string().max(2000).nullable().optional(),
  status: z.enum(taskStatusValues).optional(),
  dueDate: () =&gt; z.string().date().nullable().optional(),
});
</code></pre>
<p>One table definition gives you three outputs:</p>
<ol>
<li><p><strong>TypeScript types</strong> inferred from the columns</p>
</li>
<li><p><strong>SQL migrations</strong> generated with <code>drizzle-kit</code></p>
</li>
<li><p><strong>Zod schemas</strong> via <code>drizzle-zod</code></p>
</li>
</ol>
<p>That's useful at the database boundary. It's not a replacement for your HTTP schemas.</p>
<p>The moment you add an internal <code>archivedAt</code> column, or a computed field the API returns that's not a column, the split pays for itself. Divergence becomes a normal change instead of a painful refactor.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/b055d171-05a7-4661-8a52-73db3f28c8b5.png" alt="Service mapping a database row with internal fields to a public API response" style="display: block;" width="1100" height="320" loading="lazy">

<p><em>Figure 8: The service decides what the client may see.</em></p>
<h2 id="heading-8-how-to-define-routes-as-contracts">8. How to Define Routes as Contracts</h2>
<p>In this architecture, a route doesn't "just handle a request." A route declares the contract: method, path, request schemas, and response schemas.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/b8a19630-7447-4a9e-a643-3c950d7a88cc.png" alt="Route definition acting as the API contract fulfilled by a handler" style="display: block;" width="900" height="560" loading="lazy">

<p><em>Figure 9: The route declares the contract. The handler fulfills it.</em></p>
<pre><code class="language-typescript">import { createRoute, z } from '@hono/zod-openapi';
import * as HttpStatusCodes from '@/lib/http-status-codes';
import { jsonContent } from '@/lib/openapi/json-content';
import { jsonApiErrorContent } from '@/lib/openapi/error-schema';
import {
  createTaskSchema,
  taskParamsSchema,
  taskSchema,
} from '@/lib/schemas/task';

export const createTask = createRoute({
  tags: ['Tasks'],
  method: 'post',
  path: '/tasks',
  summary: 'Create a task',
  request: {
    body: jsonContent(createTaskSchema, 'The task to create'),
  },
  responses: {
    [HttpStatusCodes.CREATED]: jsonContent(taskSchema, 'The created task'),
    [HttpStatusCodes.UNPROCESSABLE_ENTITY]:
      jsonApiErrorContent('Validation error'),
    [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonApiErrorContent(
      'Internal server error',
    ),
  },
});

export const getTask = createRoute({
  tags: ['Tasks'],
  method: 'get',
  path: '/tasks/{id}',
  summary: 'Get a task by ID',
  request: {
    params: taskParamsSchema,
  },
  responses: {
    [HttpStatusCodes.OK]: jsonContent(taskSchema, 'The requested task'),
    [HttpStatusCodes.NOT_FOUND]: jsonApiErrorContent('Task not found'),
    [HttpStatusCodes.UNPROCESSABLE_ENTITY]:
      jsonApiErrorContent('Validation error'),
    [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonApiErrorContent(
      'Internal server error',
    ),
  },
});
</code></pre>
<p>A small helper keeps response boilerplate readable:</p>
<pre><code class="language-typescript">export function jsonContent&lt;T extends z.ZodTypeAny&gt;(
  schema: T,
  description: string,
) {
  return {
    content: {
      'application/json': { schema },
    },
    description,
  };
}
</code></pre>
<p>Named status constants replace magic numbers. You use the same constants as object keys in the route and as switch cases in the handler. That makes status codes searchable and consistent.</p>
<h2 id="heading-9-how-to-keep-handlers-thin">9. How to Keep Handlers Thin</h2>
<p>Once the route defines the contract, the handler only has to fulfill it.</p>
<pre><code class="language-typescript">export const getTask: AppRouteHandler&lt;GetTaskRoute&gt; = (c) =&gt; {
  try {
    const { id } = c.req.valid('param');
    return c.json(TaskService.get(id), HttpStatusCodes.OK);
  } catch (error) {
    const apiError = ApiError.parse(error);
    switch (apiError.statusCode) {
      case HttpStatusCodes.NOT_FOUND:
      case HttpStatusCodes.UNPROCESSABLE_ENTITY:
        return c.json(apiError.toResponseBody(), apiError.statusCode);
      default:
        return c.json(
          apiError.toResponseBody(),
          HttpStatusCodes.INTERNAL_SERVER_ERROR,
        );
    }
  }
};

export const createTask: AppRouteHandler&lt;CreateTaskRoute&gt; = (c) =&gt; {
  try {
    const body = c.req.valid('json');
    return c.json(TaskService.create(body), HttpStatusCodes.CREATED);
  } catch (error) {
    const apiError = ApiError.parse(error);
    switch (apiError.statusCode) {
      case HttpStatusCodes.UNPROCESSABLE_ENTITY:
        return c.json(apiError.toResponseBody(), apiError.statusCode);
      default:
        return c.json(
          apiError.toResponseBody(),
          HttpStatusCodes.INTERNAL_SERVER_ERROR,
        );
    }
  }
};
</code></pre>
<p>Notice what's <em>not</em> in the handler:</p>
<ul>
<li><p>No manual parsing of params or bodies</p>
</li>
<li><p>No casting to <code>any</code></p>
</li>
<li><p>No business logic</p>
</li>
</ul>
<p><code>c.req.valid('param')</code> and <code>c.req.valid('json')</code> are already validated and typed. The service owns the database work:</p>
<pre><code class="language-typescript">get(id: string): Task {
  try {
    const row = db.select().from(tasks).where(eq(tasks.id, id)).get();
    if (!row) throw new NotFoundError(`Task ${id} not found`);
    return selectTaskSchema.parse(row);
  } catch (error) {
    throw ApiError.parse(error);
  }
},
</code></pre>
<p>The service wraps its body in one <code>try/catch</code> and normalizes every failure through <code>ApiError.parse()</code>. Zod errors, custom domain errors, and unexpected driver errors all become one typed shape.</p>
<h2 id="heading-10-how-to-return-one-error-shape-everywhere">10. How to Return One Error Shape Everywhere</h2>
<p>Clients should never guess whether an error looks like <code>{ message }</code>, <code>{ error }</code>, or a raw stack trace.</p>
<p>In the demo, every failure becomes one envelope:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/85080f92-0fde-45cf-9b2e-eeb4f7ec5ef5.png" alt="Different error sources normalized through ApiError.parse into one JSON envelope" style="display: block;" width="1100" height="560" loading="lazy">

<p><em>Figure 10: Every failure path becomes one predictable error shape.</em></p>
<p>The factory that creates routers bakes that behavior in with <code>defaultHook</code>:</p>
<pre><code class="language-typescript">export function createRouter() {
  return new OpenAPIHono({
    defaultHook: (result, c) =&gt; {
      if (!result.success) {
        const apiError = ApiError.parse(result.error);
        return c.json(apiError.toResponseBody(), apiError.statusCode);
      }
    },
  });
}
</code></pre>
<p>Every router goes through that factory. A bad UUID in a path param, a missing field in a POST body, or an invalid enum in a query string all produce the same response shape before the handler runs.</p>
<p><code>ApiError.parse()</code> is the second half of the pattern:</p>
<pre><code class="language-typescript">public static parse(error: unknown): ApiError {
  if (error instanceof ApiError) return error;

  if (error instanceof ZodError) {
    return new ApiError('Validation error', {
      statusCode: 422,
      errors: error.flatten().fieldErrors,
    });
  }

  return new ApiError('Internal server error', { statusCode: 500 });
}
</code></pre>
<p>Handlers still use an explicit <code>switch</code> on status codes. That's intentional. Each route documents exactly which statuses it can return. A handler that should never emit <code>403</code> doesn't have <code>403</code> sitting in a shared helper's defaults.</p>
<p>The repetition is the point. Explicit beats clever here.</p>
<h2 id="heading-11-how-to-generate-docs-that-cant-drift">11. How to Generate Docs That Can't Drift</h2>
<p>Because schemas are attached to route definitions, OpenAPI becomes a byproduct of the code instead of a separate chore.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/aac24756-84db-48f2-936a-3370c36b3dd2.png" alt="Zod schemas flowing into route definitions, OpenAPI JSON, and Scalar reference UI" style="display: block;" width="1200" height="280" loading="lazy">

<p><em>Figure 11: Docs are generated from the same route definitions as the runtime code.</em></p>
<pre><code class="language-typescript">export function configureOpenAPI(app: OpenAPIHono) {
  app.doc('/doc', {
    openapi: '3.0.0',
    info: {
      title: 'Bulletproof Tasks API',
      version: '1.0.0',
    },
  });

  app.get(
    '/reference',
    apiReference({
      spec: { url: '/doc' },
      theme: 'kepler',
      layout: 'modern',
      pageTitle: 'Bulletproof Tasks API',
    }),
  );
}
</code></pre>
<ul>
<li><p><code>GET /doc</code> returns the raw OpenAPI JSON</p>
</li>
<li><p><code>GET /reference</code> serves an interactive Scalar explorer</p>
</li>
</ul>
<p>When you change a schema or a response code in the route file, the docs update with it. There's no second docs step to forget.</p>
<h2 id="heading-12-how-to-make-the-app-production-ready">12. How to Make the App Production-Ready</h2>
<p>Type safety at the request boundary isn't enough. Configuration and process lifecycle need the same discipline.</p>
<h3 id="heading-validate-environment-variables-at-boot">Validate Environment Variables at Boot</h3>
<pre><code class="language-typescript">import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z
    .enum(['development', 'test', 'production'])
    .default('development'),
  LOG_LEVEL: z
    .enum(['silent', 'debug', 'info', 'warn', 'error', 'fatal'])
    .default('info'),
  PORT: z.coerce.number().default(8080),
  DATABASE_URL: z.string().default('tasks.db'),
});

export const env = envSchema.parse(process.env);
</code></pre>
<p>If a required value is missing or malformed, the process exits immediately with a clear Zod error. That's much better than discovering <code>undefined</code> three requests into production.</p>
<h3 id="heading-prefer-structured-logging">Prefer Structured Logging</h3>
<p>The demo uses Pino through <code>hono-pino</code> instead of a one-line console logger. In production you want JSON logs. In development you want something readable. One middleware can do both, and every request can carry a UUID.</p>
<h3 id="heading-shut-down-cleanly">Shut Down Cleanly</h3>
<p>Docker and process managers send <code>SIGTERM</code> before they kill a process. Handle it. Close the HTTP server, then close the database handle, then exit. Without that, a SQLite file (or a Postgres connection pool) can be left in a dirty state.</p>
<h3 id="heading-keep-the-app-runtime-portable">Keep the App Runtime-Portable</h3>
<p>Hono only depends on Web Standard APIs inside <code>app</code>. That means the same application object can run on Node:</p>
<pre><code class="language-typescript">import { serve } from '@hono/node-server';
import { app } from '@/app';

serve({ fetch: app.fetch, port: env.PORT });
</code></pre>
<p>Or on an edge runtime with almost nothing else:</p>
<pre><code class="language-typescript">import { app } from '@/app';

export default app;
</code></pre>
<p>That;s not a simplified example. That's the whole adapter.</p>
<h2 id="heading-13-how-these-patterns-scale-in-a-production-app">13. How These Patterns Scale in a Production App</h2>
<p>A Tasks API is a good teaching surface. Production systems are messier: you have uploads, background jobs, multiple packages, and longer-lived workflows.</p>
<p><a href="https://github.com/otutukingsley/clipforge">ClipForge</a> is a helpful example of a production app where those same ideas show up at scale. It's a self-hostable video processing toolkit built with Node.js, Hono, Zod, Drizzle, BullMQ, and Nuxt. You upload a video and get transcription, subtitles, summaries, chapter markers, and thumbnails.</p>
<p>The architecture looks like this:</p>
<pre><code class="language-text">apps/
├── api/        # Hono REST API
├── worker/     # BullMQ video processor
└── web/        # Nuxt frontend
packages/
├── shared/     # Zod schemas, constants, shared types
└── providers/  # OpenAI, Anthropic, Deepgram integrations
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/f341110f-ef93-4b79-b0d0-9b07aeed04d2.png" alt="ClipForge architecture with web, API, worker, shared schemas, Postgres, Redis, and AI providers" style="display: block;" width="1200" height="700" loading="lazy">

<p><em>Figure 12: ClipForge keeps the same contract-first core across web, API, and worker packages.</em></p>
<p>A video job moves through stages like uploading, validating, extracting audio, and so on without inventing new response shapes along the way:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/38487b12-26fd-4185-9ca1-48560a03699e.png" alt="ClipForge video job stages from uploading through complete, with a failed path" style="display: block;" width="1400" height="360" loading="lazy">

<p><em>Figure 13: A video job moves through typed stages without inventing new response shapes.</em></p>
<p>The important part isn't the video pipeline. The important part is that the API still follows the same contract-first rules.</p>
<h3 id="heading-shared-zod-schemas-across-packages">Shared Zod Schemas Across Packages</h3>
<p>ClipForge keeps HTTP contracts in <code>packages/shared</code>, so the API, worker, and web app share one vocabulary:</p>
<pre><code class="language-typescript">export const processingFeatureSchema = z.enum([
  'transcription',
  'subtitles',
  'summary',
  'chapters',
  'thumbnails',
]);

export const videoUploadSchema = z.object({
  title: z.string().min(3).max(200),
  description: z.string().max(2000).optional(),
  features: z
    .array(processingFeatureSchema)
    .default([
      'transcription',
      'subtitles',
      'summary',
      'chapters',
      'thumbnails',
    ]),
  priority: z.enum(['low', 'normal', 'high']).default('normal'),
});

export const jobStatusSchema = z.object({
  jobId: z.string(),
  title: z.string().optional(),
  state: jobStateSchema,
  stage: jobStageSchema,
  progress: z.number().min(0).max(100),
  result: jobResultSchema.optional(),
  failedReason: z.string().optional(),
  createdAt: z.string(),
  updatedAt: z.string(),
});
</code></pre>
<p>When the worker finishes a stage, it doesn't invent a new response shape. It updates state against the same schemas the API returns to the client.</p>
<h3 id="heading-routes-still-define-the-contract">Routes Still Define the Contract</h3>
<p>The upload route in ClipForge looks like the Tasks demo, just with multipart form data and rate limiting:</p>
<pre><code class="language-typescript">export const uploadVideo = createRoute({
  tags: ['videos'],
  method: 'post',
  path: '/videos/upload',
  middleware: [
    sessionMiddleware,
    apiKeysMiddleware,
    rateLimitMiddleware({
      windowMs: 60_000,
      max: 10,
      keyPrefix: 'ratelimit:upload',
    }),
  ] as const,
  request: {
    body: {
      content: {
        'multipart/form-data': {
          schema: z.object({
            file: z.instanceof(File),
            title: z.string().min(3).max(200),
            description: z.string().max(2000).optional(),
            features: z.string().optional(),
            priority: z.enum(['low', 'normal', 'high']).optional(),
          }),
        },
      },
    },
  },
  responses: {
    [HTTP_STATUS.ACCEPTED]: jsonContent(
      jobStatusSchema,
      'Video accepted for processing',
    ),
    [HTTP_STATUS.BAD_REQUEST]: jsonApiErrorContent('Invalid request'),
    [HTTP_STATUS.UNPROCESSABLE]: jsonApiErrorContent('Validation error'),
    [HTTP_STATUS.TOO_MANY_REQUESTS]: jsonApiErrorContent('Rate limit exceeded'),
  },
});
</code></pre>
<p>The handler validates input, writes a job row, enqueues BullMQ work, and returns <code>202 Accepted</code> with a typed job status. The heavy lifting happens in the worker. The API stays a contract layer.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/5d3d8157-b1c0-4f40-82f1-9023de847a24.png" alt="Sequence diagram of ClipForge video upload, queue processing, and job status polling" style="display: block;" width="1300" height="680" loading="lazy">

<p><em>Figure 14: The API accepts the upload and returns a typed job status while the worker does the heavy lifting.</em></p>
<p>Figure 14 is a sequence diagram with five participants: Web UI, Hono API, BullMQ, Worker, and Postgres. It shows the async boundary between “accept the upload” and “finish processing.” Here's every step in the diagram:</p>
<ol>
<li><p>The Web UI sends <code>POST /videos/upload</code> to the Hono API.</p>
</li>
<li><p>The Hono API runs <code>Zod + middleware</code> (validate input, session, rate limits, and related checks).</p>
</li>
<li><p>The Hono API performs <code>insert job row</code> in Postgres so the job exists before work starts.</p>
</li>
<li><p>The Hono API sends <code>enqueue process-video</code> to BullMQ.</p>
</li>
<li><p>The Hono API immediately returns <code>202 + jobStatusSchema</code> to the Web UI. At this point the client has a typed job status, but transcription hasn't finished yet.</p>
</li>
<li><p>BullMQ later delivers <code>process job</code> to the Worker.</p>
</li>
<li><p>The Worker runs <code>transcribe / analyze / thumbnails</code> as background work.</p>
</li>
<li><p>The Worker writes <code>update stage + result</code> back to Postgres as stages complete.</p>
</li>
<li><p>The Web UI polls with <code>GET /videos/jobs/{id}</code> against the Hono API.</p>
</li>
<li><p>The Hono API reads the latest job from Postgres and returns <code>jobStatusSchema</code> agai the same response shape as step 5, now with updated <code>stage</code>, <code>progress</code>, and eventually <code>result</code>.</p>
</li>
</ol>
<p>So the API stays a fast contract layer: accept, persist, enqueue, and respond. The worker owns the slow pipeline. The UI tracks progress by polling one share status schema.</p>
<h3 id="heading-database-schemas-still-stay-separate">Database Schemas Still Stay Separate</h3>
<p>ClipForge stores jobs in Postgres with Drizzle:</p>
<pre><code class="language-typescript">export const jobs = pgTable('jobs', {
  id: varchar('id', { length: 36 }).primaryKey(),
  sessionId: varchar('session_id', { length: 36 }).notNull(),
  state: jobStateEnum('state').notNull().default('waiting'),
  stage: jobStageEnum('stage').notNull().default('uploading'),
  progress: integer('progress').notNull().default(0),
  title: varchar('title', { length: 200 }).notNull(),
  features: jsonb('features').$type&lt;string[]&gt;().notNull(),
  provider: varchar('provider', { length: 50 }).notNull().default('openai'),
  result: jsonb('result').$type&lt;JobResult&gt;(),
  failedReason: text('failed_reason'),
  createdAt: timestamp('created_at', { withTimezone: true })
    .notNull()
    .defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true })
    .notNull()
    .defaultNow(),
});
</code></pre>
<p>The table has persistence concerns like <code>filePath</code> and <code>sessionId</code>. The public <code>jobStatusSchema</code> doesn't have to expose all of them. That's the same DB-versus-API split from the Tasks demo, applied to a real workflow.</p>
<h3 id="heading-the-same-production-habits-still-apply">The Same Production Habits Still Apply</h3>
<p>ClipForge validates environment variables with Zod on boot, configures OpenAPI and Scalar at <code>/doc</code> and <code>/reference</code>, normalizes failures through <code>ApiError</code>, and shuts down queues and Redis on <code>SIGTERM</code>.</p>
<p>The lesson is simple: if the small app is structured correctly, the large app doesn't need a different philosophy. It needs more packages, more middleware, and longer-running jobs on top of the same contract-first core.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Type-safe APIs aren't about adding more TypeScript. They're about removing duplicate sources of truth.</p>
<p>With Hono and Zod you can:</p>
<ul>
<li><p>validate requests at runtime</p>
</li>
<li><p>infer types automatically</p>
</li>
<li><p>generate OpenAPI docs from the same route definitions</p>
</li>
<li><p>keep database schemas and HTTP schemas intentionally separate</p>
</li>
<li><p>return one predictable error shape from every failure path</p>
</li>
</ul>
<p>Start with the <a href="https://github.com/otutukingsley/api-conf-demo">api-conf-demo</a> if you want the smallest readable version of these patterns. Then look at <a href="https://github.com/otutukingsley/clipforge">ClipForge</a> to see how the same ideas hold up when the API sits in front of uploads, queues, and multi-stage processing.</p>
<p>Once your route definition is the contract, your docs stop drifting, your handlers get thinner, and your clients get an API they can trust.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Implement Privacy by Design in Modern APIs – A Developer's Practical Guide ]]>
                </title>
                <description>
                    <![CDATA[ As software developers, we're usually taught to prioritize features like speed, performance, and uptime. When we build APIs, our core concern is making sure data gets from Point A to Point B smoothly. ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-implement-privacy-by-design-in-modern-apis/</link>
                <guid isPermaLink="false">6a71e6ca56867ecf2f5d78c1</guid>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ APIs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ privacy ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Developer Tools ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ samiatakande ]]>
                </dc:creator>
                <pubDate>Tue, 04 Aug 2026 13:19:06 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/2c0c5da0-cf2d-4414-9486-40be7a04f727.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>As software developers, we're usually taught to prioritize features like speed, performance, and uptime. When we build APIs, our core concern is making sure data gets from Point A to Point B smoothly.</p>
<p>But data privacy regulations are tightening globally, and users are growing increasingly conscious of their digital footprints. Treating privacy as a "legal afterthought" or something to fix later in production is no longer sustainable.</p>
<p>This is where <strong>Privacy by Design</strong> comes in.</p>
<p>Coined as a framework to integrate privacy proactively into the engineering lifecycle, Privacy by Design means your system architecture should naturally protect user data by default.</p>
<p>In this comprehensive guide, we'll walk through how to structurally build data privacy into your backend APIs using modern engineering patterns, code concepts, and intentional database designs.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-principles-of-privacy-by-design-for-developers">The Principles of Privacy by Design for Developers</a></p>
</li>
<li><p><a href="#heading-project-directory-structure">Project Directory Structure</a></p>
</li>
<li><p><a href="#heading-implement-strict-data-minimization-at-the-endpoint-layer">Implement Strict Data Minimization at the Endpoint Layer</a></p>
</li>
<li><p><a href="#heading-decouple-pii-with-the-pseudonymization-token-pattern">Decouple PII with the Pseudonymization Token Pattern</a></p>
</li>
<li><p><a href="#heading-beyond-rbac-implementing-policy-based-access-control-pbac">Beyond RBAC: Implementing Policy-Based Access Control (PBAC)</a></p>
</li>
<li><p><a href="#heading-automate-data-retention-with-database-ttls-and-hooks">Automate Data Retention with Database TTLs and Hooks</a></p>
</li>
<li><p><a href="#heading-trust-is-the-ultimate-developer-metric">Trust is the Ultimate Developer Metric</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before diving into this tutorial, you should have the following:</p>
<ul>
<li><p>A foundational understanding of Node.js and JavaScript (ES6+).</p>
</li>
<li><p>Familiarity with building basic RESTful APIs using Express.</p>
</li>
<li><p>Essential knowledge of database interactions (SQL or NoSQL concepts).</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a5100d76df448adcc03e031/a423a8ed-9d57-4ab4-8617-d2ceb2fbfa31.png" alt="a423a8ed-9d57-4ab4-8617-d2ceb2fbfa31" style="display: block;" width="542" height="610" loading="lazy"></li>
</ul>
<p><em>Figure 1: The four pillars of Privacy by Design for backend APIs.</em></p>
<blockquote>
<p><strong>Diagram Breakdown:</strong></p>
<ul>
<li><p><strong>Center:</strong> Core security shield representing privacy-first system design.</p>
</li>
<li><p><strong>Top-Left (Data Minimization):</strong> Payload filtering at the endpoint layer.</p>
</li>
<li><p><strong>Top-Right (Pseudonymization):</strong> Decoupling PII via tokenization.</p>
</li>
<li><p><strong>Bottom-Left (PBAC):</strong> Purpose-driven, context-aware authorization rules.</p>
</li>
<li><p><strong>Bottom-Right (Retention &amp; TTL):</strong> Automated data expiration via database hooks.</p>
</li>
</ul>
</blockquote>
<h2 id="heading-the-principles-of-privacy-by-design-for-developers">The Principles of Privacy by Design for Developers</h2>
<p>Before writing code, we need to shift our mindset. Privacy by Design isn't about writing a better "Privacy Policy" page on a website. It means translating structural abstractions into practical engineering boundaries.</p>
<p>For an API engineer, this boils down to four distinct execution pillars:</p>
<ul>
<li><p><strong>Proactive, not reactive:</strong> Preventing privacy data leaks before they happen rather than managing breaches after the fact.</p>
</li>
<li><p><strong>Privacy as the default:</strong> The user doesn't have to opt-in to remain private. The ecosystem protects them out of the box.</p>
</li>
<li><p><strong>End-to-end security:</strong> Data remains secure, structured, and minimized from ingestion to permanent deletion.</p>
</li>
<li><p><strong>Visibility and transparency:</strong> Keeping clean logs of what data is handled, where it lives, and why it is being used.</p>
</li>
</ul>
<p>Let's look at how we can implement these four foundations directly inside our codebases.</p>
<h2 id="heading-project-directory-structure">Project Directory Structure</h2>
<p>To give you a technical perspective of how these privacy patterns fit together cleanly inside a modular production application, we'll be referencing code across the following project layout:</p>
<pre><code class="language-text">api-privacy-design/
├── config/
│   └── database.js
├── middleware/
│   ├── auth.js
│   └── privacyPolicy.js
├── models/
│   ├── auditLog.js
│   └── user.js
├── services/
│   └── piiVault.js
├── validators/
│   └── userValidator.js
├── app.js
├── package.json
└── README.md
</code></pre>
<h2 id="heading-implement-strict-data-minimization-at-the-endpoint-layer">Implement Strict Data Minimization at the Endpoint Layer</h2>
<p>The fundamental rule of data privacy is simple: <strong>If you don’t have it, you can't lose it.</strong></p>
<h3 id="heading-the-payload-over-inclusion-anti-pattern">The Payload Over-Inclusion Anti-Pattern</h3>
<p>Many APIs accept massive, generic JSON payloads from frontend clients and save everything straight to the database. Developers often do this for convenience, using spread operators like <code>...req.body</code> to quickly insert records without explicitly mapping variables.</p>
<pre><code class="language-javascript">// A poorly designed registration endpoint that grabs everything indiscriminately
app.post('/api/register', async (req, res) =&gt; {
  const userData = req.body; // Accepts full profile, tracking tokens, internal metadata, etc.
  const user = await Database.save('users', userData);
  res.status(201).json(user);
});
</code></pre>
<p>If an attacker manipulates the client-side request to pass an administrative flag like <code>{"isAdmin": true, "internalDeviceID": "123"}</code> within the payload, a naïve endpoint will process it.</p>
<h3 id="heading-architectural-view-schema-validation">Architectural View: Schema Validation</h3>
<p>Data minimization means configuring endpoints to accept and store only what is strictly necessary for immediate business operations. To enforce this, explicit schema validation must occur right at the API gateway or controller boundary. This process ensures that only predefined, safe fields enter your internal system, and everything else is structurally ignored.</p>
<h3 id="heading-the-code-solution-schema-hardening">The Code Solution: Schema Hardening</h3>
<p>By implementing strict verification models (using validation libraries like Joi, Zod, or Yup), any unmapped field injected by a client is dropped or triggers a validation block.</p>
<p>Under our <code>validators/userValidator.js</code> configuration file, we can define our schema parameters.</p>
<p>If your validation provider is Joi-compatible, you can leverage native options like <code>{ stripUnknown: true }</code> to automatically clean incoming objects. If you choose to use an alternate validation framework like Zod, you can achieve an identical outcome by chaining the <code>.strict()</code> modifier to block requests that contain unmapped fields entirely.</p>
<p>Here's how we use Joi to drop non-explicit fields instantly at our router boundary:</p>
<pre><code class="language-javascript">const Joi = require('joi');

// Explicitly define the bare minimum schema required for validation
const registrationSchema = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().min(8).required()
  // Any extra tracking metadata or unauthorized attributes injected here are dropped automatically
});

app.post('/api/register', async (req, res) =&gt; {
  try {
    // stripUnknown: true drops any properties not explicitly defined in the schema
    const validatedData = await registrationSchema.validateAsync(req.body, { stripUnknown: true });
    
    const user = await Database.save('users', validatedData);
    
    // Privacy Safeguard: Never return raw internal properties back to the client response
    res.status(201).json({ id: user.id, email: user.email }); 
  } catch (err) {
    res.status(400).json({ error: err.details[0].message });
  }
});
</code></pre>
<h2 id="heading-decouple-pii-with-the-pseudonymization-token-pattern">Decouple PII with the Pseudonymization Token Pattern</h2>
<p>When handling Personally Identifiable Information (PII) like real names, phone numbers, or physical home addresses, storing them in plain text inside your primary application tables is a massive security liability.</p>
<h3 id="heading-why-simple-encryption-isnt-enough">Why Simple Encryption Isn't Enough</h3>
<p>While encrypting columns at rest (using AES-256) is standard practice, application systems still face risks. If developers run analytical reporting, dump operational data into debugging environments, or accidentally output database rows to application logs (for example, <code>console.log(userObject)</code>), raw sensitive data can quickly spill across unauthorized environments.</p>
<h3 id="heading-the-tokenization-architecture">The Tokenization Architecture</h3>
<p>Instead of keeping PII alongside business tables, use a <strong>Pseudonymization Token Pattern</strong>. This separates identity from transactional records. This isn't just encryption anymore; it's an architectural separation of duties.</p>
<h3 id="heading-implementing-a-pseudonymized-data-flow">Implementing a Pseudonymized Data Flow</h3>
<p>Let's trace how the processing layer functions. In our isolated <code>services/piiVault.js</code> module, we manage the interaction between our primary app controller and our secure token storage vault. When a record initialisation occurs, we isolate the raw PII elements, exchange them for an opaque reference token, and use that reference token as our relational key.</p>
<pre><code class="language-javascript">// services/piiVault.js
// Conceptual abstraction of a decoupled user account initialization
async function createUserProfile(incomingPayload) {
  const { email, phoneNumber, ...transactionalData } = incomingPayload;

  // 1. Dispatch the raw PII elements directly to an isolated, encrypted Vault Service
  const vaultResponse = await PiiVaultService.tokenize({
    email: email,
    phone: phoneNumber
  });

  // 2. The Vault returns a uniquely structured reference UUID token (non-reversible string)
  const piiToken = vaultResponse.token; // e.g., "pii_token_83912x"

  // 3. Save the primary application record using ONLY the token link
  const operationalRecord = {
    ...transactionalData,
    piiRefToken: piiToken,
    accountStatus: 'active',
    createdAt: new Date()
  };

  await Database.save('primary_users_table', operationalRecord);
  return { success: true, reference: piiToken };
}
</code></pre>
<p>If internal developers run analytical operations or debug logs on the primary backend database, they'll only ever see non-identifiable reference strings rather than actual user credentials.</p>
<h2 id="heading-beyond-rbac-implementing-policy-based-access-control-pbac">Beyond RBAC: Implementing Policy-Based Access Control (PBAC)</h2>
<p>Traditional Role-Based Access Control (RBAC) (checking patterns like <code>if (user.role === 'admin')</code>) is no longer granular enough for complex software architectures. Privacy engineering demands that access control systems evaluate not just <em>who</em> is trying to view a resource, but the <em>purpose</em> of the access request.</p>
<h3 id="heading-the-core-vectors-of-pbac">The Core Vectors of PBAC</h3>
<p>By transitioning to Policy-Based Access Control (PBAC), your authorization system dynamically maps rules along three parameters:</p>
<ul>
<li><p><strong>The Actor/User:</strong> Who is making the API call and what are their active permissions?</p>
</li>
<li><p><strong>The Resource:</strong> What specific classification of data is being requested?</p>
</li>
<li><p><strong>The Purpose:</strong> Why does the business logic need to process this data, and did the data owner explicitly give consent for it?</p>
</li>
</ul>
<h3 id="heading-building-contextual-privacy-governance-middleware">Building Contextual Privacy Governance Middleware</h3>
<p>Let's see how this works in practice. To set the context for our route execution, we'll build a dedicated authorization middleware file under <code>middleware/privacyPolicy.js</code>. This middleware intercepts the incoming request vector, checks the destination resource owner's privacy choices, and matches it against the application's processing target context.</p>
<pre><code class="language-javascript">// middleware/privacyPolicy.js
// Middleware pattern verifying purpose-bound consent parameters
const verifyDataAccessPolicy = (requiredPurpose) =&gt; {
  return (req, res, next) =&gt; {
    const actor = req.user; // Instantiated from a previous authentication layer
    const resourceOwner = req.resourceOwner; // The target data record context
    
    // Extract the explicit consent array configured by the user
    const userConsentPolicies = resourceOwner.consents || []; // e.g., ['essential_auth', 'marketing_emails']

    // Check if the code execution purpose matches what the user explicitly consented to
    const hasExplicitConsent = userConsentPolicies.includes(requiredPurpose);
    
    // Elevate override permissions ONLY to specialized audit actors if explicitly tracked
    if (!hasExplicitConsent &amp;&amp; actor.role !== 'System_Auditor') {
      return res.status(403).json({ 
        error: "Access Denied: The operation requested exceeds your current purpose-bound user consent profiles." 
      });
    }
    
    // Access authorized; proceed down the middleware track
    next();
  };
};

// Application Execution Target Route within app.js
app.get('/api/analytics/user-behavior/:userId', 
  fetchResourceOwnerMiddleware, 
  verifyDataAccessPolicy('analytics_tracking'), // Will throw a 403 if user opted out of tracking
  (req, res) =&gt; {
    res.json({ status: "Success", data: "Contextual processing executed." });
  }
);
</code></pre>
<h2 id="heading-automate-data-retention-with-database-ttls-and-hooks">Automate Data Retention with Database TTLs and Hooks</h2>
<p>Data privacy frameworks emphasize that user data shouldn't sit inside database storage indefinitely. If a temporary log has fulfilled its purpose, or an account lifecycle ends, that data needs to disappear entirely.</p>
<h3 id="heading-the-automated-retention-lifecycle">The Automated Retention Lifecycle</h3>
<p>Relying on teams to run manual script updates or cron cleanup commands is prone to failure. Privacy-by-design systems build data expiration rules directly into their data models.</p>
<h3 id="heading-document-stores-native-ttl-indexes">Document Stores: Native TTL Indexes</h3>
<p>If you're managing unstructured payload layers or tracking sessions in NoSQL document collections (like MongoDB), you can leverage Time-To-Live (TTL) index configurations to wipe records automatically down to the second.</p>
<p>We write this logic directly into our schemas inside <code>models/auditLog.js</code>:</p>
<pre><code class="language-javascript">// models/auditLog.js
const mongoose = require('mongoose');

const auditLogSchema = new mongoose.Schema({
  userId: mongoose.Schema.Types.ObjectId,
  apiAction: String,
  ipAddress: String,
  createdAt: { 
    type: Date, 
    default: Date.now, 
    expires: '90d' // MongoDB background threads automatically drop this document after 90 days
  }
});

const AuditLog = mongoose.model('AuditLog', auditLogSchema);
</code></pre>
<h3 id="heading-relational-databases-safe-masking-with-orm-hooks">Relational Databases: Safe Masking with ORM Hooks</h3>
<p>In relational database systems (such as PostgreSQL or MySQL using Sequelize/Prisma), cascading relationships make immediate hard deletions tricky.</p>
<p>To avoid breaking database foreign keys while still respecting user privacy, we implement a two-step pattern: an instantaneous lifecycle hook handles data anonymisation and masking, while a native database scheduled script handles data cleansing asynchronously.</p>
<p>We can implement this hook inside our relational schema files under <code>models/user.js</code>:</p>
<pre><code class="language-javascript">// models/user.js
const { Sequelize, DataTypes } = require('sequelize');
const sequelize = new Sequelize('sqlite::memory:');

const OperationalUser = sequelize.define('OperationalUser', {
  username: DataTypes.STRING,
  email: DataTypes.STRING,
  fullName: DataTypes.STRING,
  isDeleted: { type: DataTypes.BOOLEAN, defaultValue: false }
});

// Anonymize records on the fly before a soft-deletion hook runs
OperationalUser.addHook('beforeUpdate', async (user, options) =&gt; {
  if (user.isDeleted &amp;&amp; user.changed('isDeleted')) {
    // Mask and overwrite confidential fields to protect privacy while keeping non-PII metrics intact
    user.email = `anonymized_user_${Date.now()}@privacy-protected.org`;
    user.fullName = "Anonymized Profile Data";
    user.username = `archived_node_${Math.floor(Math.random() * 100000)}`;
  }
});
</code></pre>
<h2 id="heading-trust-is-the-ultimate-developer-metric">Trust is the Ultimate Developer Metric</h2>
<p>Building high-throughput applications quickly is a fantastic skill, but engineering architectures that stand the test of time requires building for trust.</p>
<p>When you embed consent boundaries, strict payload validation schema controls, decoupled identifier vaults, and automated database retention routines into your backend services from day one, you aren't just checking boxes off a security compliance form. You're building reliable, loosely coupled code systems that mitigate data breach risks and treat user privacy as a first-class citizen in application design.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Serve a Multi-User AI Agent with FastAPI and Streamlit ]]>
                </title>
                <description>
                    <![CDATA[ In this tutorial, I’ll show you how to serve a multi-user local AI agent as a REST API using FastAPI, then add a lightweight Streamlit UI on top. Instead of interacting with the agent through a termin ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-serve-a-multi-user-ai-agent-with-fastapi-and-streamlit/</link>
                <guid isPermaLink="false">6a5e9c35892c69a16fdf27df</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai-agent ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ollama ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #qwen ]]>
                    </category>
                
                    <category>
                        <![CDATA[ FastAPI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ streamlit ]]>
                    </category>
                
                    <category>
                        <![CDATA[ UI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ streaming ]]>
                    </category>
                
                    <category>
                        <![CDATA[ chatgpt ]]>
                    </category>
                
                    <category>
                        <![CDATA[ agentic ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Streaming API ]]>
                    </category>
                
                    <category>
                        <![CDATA[ langgraph ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Darsh Shah ]]>
                </dc:creator>
                <pubDate>Mon, 20 Jul 2026 22:07:49 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e5bf4093-e618-4388-954c-f1a49bc87cfe.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this tutorial, I’ll show you how to serve a multi-user local AI agent as a REST API using FastAPI, then add a lightweight Streamlit UI on top.</p>
<p>Instead of interacting with the agent through a terminal, we’ll expose it over HTTP so multiple users can access it through a chat-style frontend interface. Each session will maintain its own conversation history and streamed responses.</p>
<p>The local AI agent will be built with LangChain v1, Ollama, Qwen, and Python, running on your own machine and ready to plug into larger applications without any per-call model API charges.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-background">Background</a></p>
</li>
<li><p><a href="#heading-what-is-fastapi">What is FastAPI</a>?</p>
</li>
<li><p><a href="#heading-what-is-streamlit">What is Streamlit</a>?</p>
</li>
<li><p><a href="#heading-what-is-multi-user-support">What Is Multi-User Support</a>?</p>
</li>
<li><p><a href="#heading-motivation-and-architecture">Motivation and Architecture</a></p>
</li>
<li><p><a href="#heading-step-1-install-ollama-and-pull-the-model">Step 1: Install Ollama and Pull the Model</a></p>
</li>
<li><p><a href="#heading-step-2-install-python-dependencies">Step 2: Install Python Dependencies</a></p>
</li>
<li><p><a href="#heading-step-3-build-the-agent-and-api-layer-with-fastapi">Step 3: Build the agent and API layer with FastAPI</a></p>
</li>
<li><p><a href="#heading-step-4-build-streamlit-ui">Step 4: Build Streamlit UI</a></p>
</li>
<li><p><a href="#heading-step-5-run-the-backend-app">Step 5: Run the backend app</a></p>
</li>
<li><p><a href="#heading-step-6-run-the-frontend-app">Step 6: Run the frontend app</a></p>
</li>
<li><p><a href="#heading-sample-output">Sample Output</a></p>
</li>
<li><p><a href="#heading-what-to-improve-before-production">What to Improve Before Production</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-background">Background</h2>
<p>Many AI agents start out as simple Python scripts that run in a command-line terminal. You type a message, the agent responds, and everything happens in a single local session.</p>
<p>That setup is great for development and testing, but it becomes limiting when you want other people or applications to interact with the agent.</p>
<p>To make an AI agent truly useful, we need to expose it through an interface that other users can access. A REST API is a practical way to do that.</p>
<p>To follow this tutorial, you'll need Ollama installed on your machine. The tutorial works on macOS, Windows, and Linux. I'm using a MacBook Pro with 32 GB of RAM, but you can run this on a lower-memory machine by choosing a smaller Qwen model from Ollama.</p>
<h2 id="heading-what-is-fastapi"><strong>What is FastAPI?</strong></h2>
<p><a href="https://github.com/fastapi/fastapi">FastAPI</a> is a Python web framework for building APIs. In this tutorial, it gives us a simple way to expose the agent over HTTP so other apps, scripts, or services can call it.</p>
<p>FastAPI is a good fit for AI apps because it gives us a clean boundary around the system. We define the request and response models in Python, FastAPI validates them automatically, and it turns HTTP requests into Python objects and Python objects back into JSON. It also generates interactive API docs for free and supports async endpoints, which is useful for AI workloads that may take longer to respond.</p>
<h2 id="heading-what-is-streamlit"><strong>What is Streamlit?</strong></h2>
<p><a href="https://streamlit.io">Streamlit</a> is a Python framework for building lightweight web interfaces with minimal frontend work. It lets us create interactive browser-based apps using normal Python code instead of HTML, CSS, and JavaScript.</p>
<p>In this tutorial, Streamlit sits on top of the FastAPI backend as a thin client. FastAPI exposes the AI agent over HTTP, and Streamlit gives us a simple UI for calling that API and displaying the results. That separation keeps the backend reusable while still making the agent easy to use in the browser.</p>
<h2 id="heading-what-is-multi-user-support"><strong>What Is Multi-User Support?</strong></h2>
<p>Multi-user support means the AI agent can handle requests from more than one user while keeping each user’s session separate.</p>
<p>For example, User 1&nbsp;asks the agent one question and User 2&nbsp;asks a different question. The agent should remember the correct context for each user independently. Without multi-user support, all users may end up sharing the same conversation state, which can lead to mixed responses, incorrect memory, or overwritten context.</p>
<h2 id="heading-motivation-and-architecture"><strong>Motivation and Architecture</strong></h2>
<p>Turning an AI agent into an API is the natural next step after building it locally. A Python script is great for experimenting, but an API makes the agent reusable. And adding multi-user support makes the agent extensible to be used by others.</p>
<p>To keep things simple, we’ll use a small local agent powered by Ollama and Qwen. The agent has two tools: one for checking the current time and another for counting words.</p>
<p>FastAPI provides the HTTP layer by exposing one endpoint called <code>/chat/stream</code>. When the request comes in with a user message, Pydantic validates the request, LangChain handles the agent loop and tool calling, and the final answer is returned as stream. Streamlit sits on top of that API and acts as a frontend that sends requests to the API and displays the results.</p>
<img src="https://cdn.hashnode.com/uploads/covers/684c95e159698b4bf6a0e4be/21a2b03d-b4c3-4211-82b1-aa265ac6fb1e.png" alt="image showing the sequence diagram of user calling the streamlit UI. The it goes to FastAPI layer, then to AI agent and finally Qwen and tool calls" style="display: block;" width="1478" height="1000" loading="lazy">

<p>Example request:</p>
<pre><code class="language-json">{ 
    "message": "How many words are in: LangChain makes tool calling easier",
    "user_id":"123e4567-e89b-12d3-a456-426614174000"
 }
</code></pre>
<p>Example response:</p>
<pre><code class="language-json">{
  "answer": "There are **5** words in LangChain makes tool calling easier."
}
</code></pre>
<p>The model runs locally through Ollama, so there are no per-call model API charges.</p>
<h2 id="heading-step-1-install-ollama-and-pull-the-model"><strong>Step 1: Install Ollama and Pull the Model</strong></h2>
<p>To get started, install the Ollama application for your platform.</p>
<p>We’ll use Qwen as the chat model. I’m using <code>qwen3.5:4b</code>. If your machine has less RAM, you can use <code>qwen3.5:0.8b</code> instead.</p>
<pre><code class="language-plaintext">ollama pull qwen3.5:4b
</code></pre>
<h2 id="heading-step-2-install-python-dependencies"><strong>Step 2: Install Python Dependencies</strong></h2>
<p>Create a virtual environment and install the required packages:</p>
<pre><code class="language-plaintext">python3 -m venv venv
source venv/bin/activate

pip install fastapi uvicorn streamlit requests langchain langchain-core langchain-ollama langgraph
</code></pre>
<p>If tutorial requires LangChain &gt;= 1.0.0.</p>
<h2 id="heading-step-3-build-the-agent-and-api-layer-with-fastapi">Step 3: <strong>Build the Agent and API Layer with FastAPI</strong></h2>
<p>This application has three main responsibilities. FastAPI exposes the HTTP endpoint, Pydantic validates the incoming request data, and LangChain runs the agent, including tool calling and short-term memory.</p>
<p>The <code>user_id</code> sent with each request is used as the thread identifier, allowing the checkpointer to keep each user’s conversation history separate. This memory is per session. So every new session will have its own memory.</p>
<p>Another important detail is that the agent is created only once at startup with <code>agent = build_agent()</code>. Reusing the same agent instance avoids rebuilding the model and tool list for every request, which reduces overhead and improves response times while still supporting multiple users.</p>
<p>Inside the <code>/chat/stream</code> endpoint, the backend uses <a href="https://docs.langchain.com/oss/python/langchain/event-streaming">LangChain’s</a> <code>stream_events(..., version="v3")</code> to generate the response as a stream instead of waiting for the full answer all at once. FastAPI then wraps that stream in a <code>StreamingResponse</code>, so the frontend can receive the output gradually as it's produced. This makes the app feel much more interactive, because users can start reading the answer immediately while the rest is still being generated.</p>
<p>Put together, this gives you a lightweight backend that validates input, preserves separate memory for each user, and streams responses to the UI in real time.</p>
<p>Save the following code as <code>app.py</code>:</p>
<pre><code class="language-python">from datetime import datetime
from uuid import UUID

from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse

from pydantic import BaseModel

from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from langgraph.checkpoint.memory import InMemorySaver

CHAT_MODEL = "qwen3.5:4b"

SYSTEM_PROMPT = (
    "You are a helpful assistant with access to tools for getting the current time "
    "and counting words in text. "
    "Use tools when needed. If the question does not need a tool, answer directly."
)

# -----------------------------
# Request model
# -----------------------------

class ChatRequest(BaseModel):
    user_id: UUID
    message: str

# -----------------------------
# Tools
# -----------------------------

@tool
def current_time() -&gt; str:
    """Return the current local date and time."""
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")


@tool
def word_count(text: str) -&gt; int:
    """Count the number of words in a piece of text."""
    return len(text.split())


# -----------------------------
# Agent + checkpoint memory
# -----------------------------

# Store conversation history in short term memory
checkpointer = InMemorySaver()

def build_agent():
    model = ChatOllama(model=CHAT_MODEL, temperature=0)
    return create_agent(
        model=model,
        tools=[current_time, word_count],
        system_prompt=SYSTEM_PROMPT,
        checkpointer=checkpointer,
    )


agent = build_agent()

# -----------------------------
# Streaming endpoint
# -----------------------------

app = FastAPI()

@app.post("/chat/stream")
def chat_stream(req: ChatRequest):
    def generate():
        run = agent.stream_events(
            {
                "messages": [{"role": "user", "content": req.message}],
            },
            config={
                "configurable": {
                    # Keep each user's short-term memory isolated
                    # by using their user_id as the thread ID.
                    "thread_id": str(req.user_id),
                }
            },
            version="v3",
        )

        for message in run.messages:
            for token in message.text:
                yield token

    return StreamingResponse(generate(), media_type="text/plain")
</code></pre>
<h2 id="heading-step-4-build-streamlit-ui">Step 4: Build Streamlit UI</h2>
<p>The Streamlit code creates a simple chat interface for the AI agent and keeps each browser session tied to a unique user_id.</p>
<p>When the app first loads, it generates and stores a UUID in st.session_state, which is later sent to the backend so the agent can keep that user’s conversation history separate from other users. It also creates a chat_history list in session state so previous messages remain visible every time Streamlit reruns the script. The app then loops through that saved history and displays each message in a chat-style format using st.chat_message().</p>
<p>When the user enters a new message through st.chat_input(), the app immediately saves and displays it, then sends it to the backend API with a POST request to <code>http://127.0.0.1:8001/chat/stream</code> along with the session’s user_id.</p>
<p>The request is made with stream=True, which allows the response to arrive gradually instead of all at once. As each chunk of text is received from the backend, the code appends it to full_answer and updates a placeholder on the page, creating a live streaming effect. Once the response is complete, the final assistant message is stored in chat_history so it remains part of the conversation on the page</p>
<p>Save the below as <code>streamlit_app.py</code></p>
<pre><code class="language-python">import uuid
import requests
import streamlit as st

API_URL = "http://127.0.0.1:8001/chat/stream"

st.title("Local AI Agent")

if "user_id" not in st.session_state:
    st.session_state.user_id = str(uuid.uuid4())

if "chat_history" not in st.session_state:
    st.session_state.chat_history = []

# Show previous messages
for item in st.session_state.chat_history:
    with st.chat_message(item["role"]):
        st.markdown(item["content"])

message = st.chat_input("Enter a message")

if message:
    # Save and show user message
    st.session_state.chat_history.append({"role": "user", "content": message})
    with st.chat_message("user"):
        st.markdown(message)

    # Stream assistant response
    full_answer = ""
    with st.chat_message("assistant"):
        placeholder = st.empty()

        # Send the reqeust to backend API via POST request
        with requests.post(
            API_URL,
            json={
                "message": message,
                "user_id": st.session_state.user_id,
            },
            stream=True,
        ) as response:
            response.raise_for_status()

            for chunk in response.iter_content(chunk_size=None, decode_unicode=True):
                if chunk:
                    full_answer += chunk
                    placeholder.markdown(full_answer)

    # Save final assistant response
    st.session_state.chat_history.append(
        {"role": "assistant", "content": full_answer}
    )
</code></pre>
<h2 id="heading-step-5-run-the-backend-app">Step 5: Run the Backend App</h2>
<p>Start the server with Uvicorn:</p>
<pre><code class="language-bash">uvicorn app:app --reload --port 8001
</code></pre>
<p>Once the application starts, open:</p>
<ul>
<li><p><code>http://127.0.0.1:8001/</code></p>
</li>
<li><p><code>http://127.0.0.1:8001/docs</code></p>
</li>
</ul>
<p>The <code>/docs</code> endpoint is automatically generated by FastAPI using your Pydantic models. It provides an interactive interface where you can test the API without writing any client code.</p>
<img src="https://cdn.hashnode.com/uploads/covers/684c95e159698b4bf6a0e4be/5cf32ff0-273c-47cd-80be-ebf807e4443d.png" alt="Api docs that was generated by FastAPI. It includes /chat/stream  endpoint and schema" style="display: block;" width="2712" height="1034" loading="lazy">

<p>You can send requests directly from <code>curl</code>. In your terminal, run these commands to invoke the API for the AI agent and check the output:</p>
<pre><code class="language-bash">$ curl -X POST http://127.0.0.1:8001/chat/stream \
  -H "Content-Type: application/json" \
  -d '{"message":"What time is it?","user_id":"123e4567-e89b-12d3-a456-426614174000"}'

$ curl -X POST http://127.0.0.1:8001/chat/stream \
  -H "Content-Type: application/json" \
  -d '{"message":"How many words are in: LangChain makes tool calling easier","user_id":"123e4567-e89b-12d3-a456-426614174000"}'

$ curl -X POST "http://127.0.0.1:8001/chat/stream" \
-H "Content-Type: application/json" \
-d '{"message":"What is the capital of France?","user_id":"123e4567-e89b-12d3-a456-426614174000"}'
</code></pre>
<p>To stop the server, press Ctrl+C in the terminal.</p>
<h2 id="heading-step-6-run-the-frontend-app"><strong>Step 6: Run the Frontend App</strong></h2>
<p>In another terminal, go to the project directory:</p>
<pre><code class="language-plaintext">source venv/bin/activate
streamlit run streamlit_app.py
</code></pre>
<p>That opens the frontend in your browser at <code>http://localhost:8501/</code>. Try the example prompts like "What is the capital of France". You should see the answer in a chat style interface.</p>
<img src="https://cdn.hashnode.com/uploads/covers/684c95e159698b4bf6a0e4be/1030735a-49ed-43e1-995d-07b122c2c965.png" alt="Streamlit UI provides a simple chat frontend for the local AI agent" style="display: block;" width="1848" height="1710" loading="lazy">

<p>The UI is calling the FastAPI endpoint and invoking the AI agent. You now have a working end to end application for your local AI agent that you can play with.</p>
<p>To stop the server, press Ctrl+C in the terminal.</p>
<h2 id="heading-sample-output">Sample Output</h2>
<p>The image below show two browser sessions of the app running side by side on the same endpoint. Each session is assigned a unique id, which allows the backend to maintain a separate conversation history for each user.</p>
<p>Even though both users ask the same question, “Who am I?”, the responses are different because each session’s answer is based on its own prior messages.</p>
<img src="https://cdn.hashnode.com/uploads/covers/684c95e159698b4bf6a0e4be/b97b8efa-6fca-4e80-9c0a-d0d2601fc2b6.png" alt="Image showing two sessions with the agent and it gives different answers based on the the conversation history" style="display: block;" width="2914" height="1906" loading="lazy">

<h2 id="heading-what-to-improve-before-production">What to Improve Before Production</h2>
<p>Although this application is fully functional, it's still intentionally minimal. It already supports a reusable FastAPI backend, a Streamlit chat interface, per-user conversation history, and streaming responses.</p>
<p>If you wanted to take it further, the next steps would be adding authentication, persistent storage, structured logging, monitoring, and more robust deployment setup.</p>
<p>It's also worth noting that if your goal is simply to get a polished self-hosted chat UI up and running quickly, you may not need to build the frontend yourself. Projects like <a href="https://www.librechat.ai/">LibreChat</a> and <a href="https://docs.openwebui.com/">Open WebUI</a> already provide richer interfaces and broader features out of the box.</p>
<p>This tutorial takes a different approach: instead of adopting a full platform, it shows how to build a lightweight custom stack yourself so you can better understand the architecture and have more control over how the agent is exposed.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, we took a local AI agent, wrapped it in a FastAPI app, and used Streamlit UI on top of it.</p>
<p>This transforms the AI agent from a standalone script into a reusable service. Instead of only working in a terminal, it can now be accessed through a simple HTTP endpoint by other apps, scripts, or internal tools.</p>
<p>By assigning each session a unique id, the service can also maintain separate conversation history for multiple users, making it possible to support a chat-style interface with isolated memory per session.</p>
<p>From here, you can continue extending the same service by adding authentication or production-ready features. Happy tinkering!</p>
<p>If you enjoyed this tutorial, you can find more of my writing on my&nbsp;<a href="https://darshshah.org/blog/">blog</a>&nbsp;(recent posts include system design paper series), my work on my&nbsp;<a href="https://darshshah.org/">personal website</a>, and updates on&nbsp;<a href="https://www.linkedin.com/in/darshs">LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Multi-Tenant SaaS API with Node.js, RBAC, and Audit Logging ]]>
                </title>
                <description>
                    <![CDATA[ A colleague asked me to help debug what looked like a permissions issue in their SaaS project management tool. Users were seeing resources they hadn't created. I pulled up the query logs expecting som ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-multi-tenant-saas-api-with-nodejs-rbac-and-audit-logging/</link>
                <guid isPermaLink="false">6a5e8914bc397f89a942b88b</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ PostgreSQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ backend ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Mon, 20 Jul 2026 20:46:12 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/69793fe0-fe0e-4c9c-839d-12a134f65287.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A colleague asked me to help debug what looked like a permissions issue in their SaaS project management tool. Users were seeing resources they hadn't created.</p>
<p>I pulled up the query logs expecting something subtle. It was not. The list endpoint had no <code>tenant_id</code> filter at all. Every tenant in the database could read every other tenant's projects. The application never threw an error. It just returned whatever was there.</p>
<p>Missing tenant filters don't throw errors. They return the wrong data without any complaint, and nothing in your logs will flag it. I've seen this run in production for weeks before a support ticket pointed anyone at the query logs.</p>
<p>When it does surface, who finds it first matters a lot. A customer noticing it is bad. A compliance auditor noticing it during a SOC 2 review is a different kind of problem.</p>
<p>Isolation built in from the start is a day of work. The time I spent helping a team retrofit it after a compliance review was considerably longer than that, and involved more customer emails than anyone wanted to write.</p>
<p>The stack is Node.js with PostgreSQL. CRUD is the easy part. Tenant isolation, RBAC, and audit logging take more care, and where those checks run in the stack matters. I put all three in middleware, before any route handler fires. A handler that never calls the isolation logic directly can't accidentally skip it.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 18+</p>
</li>
<li><p>PostgreSQL 14+</p>
</li>
<li><p>Basic knowledge of Express.js and JWT</p>
</li>
</ul>
<h2 id="heading-what-we-will-build">What We Will Build</h2>
<p>A multi-tenant Express REST API that enforces:</p>
<ol>
<li><p><strong>Tenant isolation:</strong> every database query scopes to the <code>tenant_id</code> from the verified JWT. The client can't influence which tenant the query runs against.</p>
</li>
<li><p><strong>RBAC:</strong> four roles, each with a numeric level (SuperAdmin is highest, Viewer lowest). Middleware checks the level before the handler runs.</p>
</li>
<li><p><strong>Audit logging:</strong> any write or sensitive read appends a row to the audit table. The app can't modify those rows afterward. The database enforces this directly. If a bug in the app tries to UPDATE an audit row, the database refuses it. Application-level enforcement alone can't give you that guarantee.</p>
</li>
<li><p><strong>Per-tenant rate limiting:</strong> request counts in Redis, keyed to the tenant. I've seen IP-based limiting break an enterprise rollout when fifty users came through a single corporate proxy.</p>
</li>
<li><p><strong>Tenant isolation tests:</strong> a dedicated test file that proves cross-tenant data can't leak. Wire it into CI and it catches broken isolation before it ships.</p>
</li>
</ol>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-how-multi-tenancy-works">How Multi-Tenancy Works</a></p>
</li>
<li><p><a href="#heading-architecture-overview">Architecture Overview</a></p>
</li>
<li><p><a href="#heading-database-schema-design">Database Schema Design</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-jwt-design-for-multi-tenancy">JWT Design for Multi-Tenancy</a></p>
</li>
<li><p><a href="#heading-auth-and-rbac-middleware">Auth and RBAC Middleware</a></p>
</li>
<li><p><a href="#heading-the-tenant-safe-repository-layer">The Tenant-Safe Repository Layer</a></p>
</li>
<li><p><a href="#heading-audit-logging-service">Audit Logging Service</a></p>
</li>
<li><p><a href="#heading-per-tenant-rate-limiting">Per-Tenant Rate Limiting</a></p>
</li>
<li><p><a href="#heading-building-the-routes">Building the Routes</a></p>
</li>
<li><p><a href="#heading-testing-tenant-isolation">Testing Tenant Isolation</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-how-multi-tenancy-works">How Multi-Tenancy Works</h2>
<p>This tutorial uses a <strong>shared database with row-level isolation</strong>: a <code>tenant_id</code> column on every table, a filter on every query. The database holds everyone's data together. The application decides what each tenant can see.</p>
<p>Two other approaches exist: schema-per-tenant and database-per-tenant. I've talked to teams on schema-per-tenant who ended up spending more engineering time on migration tooling than on their actual product. Database-per-tenant gives stronger guarantees but a connection pool that balloons with every new customer signup.</p>
<p>Neither scales cheaply. Row-level isolation scales further than most teams expect. The ones I know who moved off it did so years in, usually under specific regulatory pressure, not because the approach stopped working.</p>
<p>The one thing in this design that can't be optional: <code>tenant_id</code> <strong>must always come from the verified JWT.</strong> Not from the request body, not from the URL. Users control what they put in both of those. They don't control what gets signed into a JWT on your server.</p>
<h2 id="heading-architecture-overview">Architecture Overview</h2>
<pre><code class="language-plaintext">HTTP Request
     │
     ▼
┌─────────────────────────────────────────┐
│           Express Middleware Stack       │
│                                         │
│  1. Rate Limiter (per tenant_id)        │
│  2. Auth Middleware (verify JWT)        │
│     └─► Extracts: userId, tenantId,    │
│          role, permissions              │
│  3. RBAC Middleware (check role)        │
└──────────────┬──────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────┐
│           Route Handler                  │
│                                         │
│  1. Call Repository (tenant-safe query) │
│  2. Call Audit Service (fire &amp; forget)  │
│  3. Return response                     │
└──────────────┬──────────────────────────┘
               │
     ┌─────────┴──────────┐
     ▼                    ▼
┌─────────┐        ┌────────────┐
│ Projects│        │ Audit Logs │
│  Table  │        │   Table    │
│(+tenant)│        │(append only│
└─────────┘        └────────────┘
</code></pre>
<p>Rate limiting, auth, and RBAC all run before any handler sees the request. Writes pass through the audit service. The repository takes <code>tenantId</code> from <code>req.user</code> and the handler never touches tenant scoping directly, so there's no path around it.</p>
<h2 id="heading-database-schema-design">Database Schema Design</h2>
<pre><code class="language-sql">-- Tenants table
CREATE TABLE tenants (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name        VARCHAR(255) NOT NULL,
  plan        VARCHAR(50) NOT NULL DEFAULT 'free', -- 'free', 'pro', 'enterprise'
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Users table
CREATE TABLE users (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  email       VARCHAR(255) NOT NULL,
  role        VARCHAR(50) NOT NULL DEFAULT 'Member', -- 'SuperAdmin','TenantAdmin','Member','Viewer'
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE(tenant_id, email)
);

CREATE INDEX idx_users_tenant ON users(tenant_id);

-- Projects table (example resource — replace with your domain entity)
CREATE TABLE projects (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name        VARCHAR(255) NOT NULL,
  description TEXT,
  created_by  UUID NOT NULL REFERENCES users(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_projects_tenant ON projects(tenant_id);

-- Audit log table (append-only — never UPDATE or DELETE rows here)
CREATE TABLE audit_logs (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL,
  user_id     UUID NOT NULL,
  user_email  TEXT NOT NULL,
  user_role   TEXT NOT NULL,        -- role at time of action
  action      TEXT NOT NULL,        -- 'CREATE', 'UPDATE', 'DELETE', 'VIEW'
  resource    TEXT NOT NULL,        -- table name
  resource_id TEXT,
  old_values  JSONB,
  new_values  JSONB,
  ip_address  INET,
  user_agent  TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_audit_tenant ON audit_logs(tenant_id);
CREATE INDEX idx_audit_created ON audit_logs(created_at DESC);

-- Protect audit log at database level
-- Use a DO block so this runs safely in Docker where app_user is the superuser
DO $$
BEGIN
  IF current_user &lt;&gt; 'app_user' THEN
    REVOKE DELETE, UPDATE ON audit_logs FROM app_user;
  END IF;
END $$;
</code></pre>
<p>The <code>REVOKE</code> matters. Application bugs happen. If something in your codebase accidentally tries to UPDATE an audit row, you want the database to refuse it outright, not silently comply.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<pre><code class="language-bash">mkdir nodejs-multitenant-saas-api
cd nodejs-multitenant-saas-api
npm init -y
npm install express pg jsonwebtoken bcryptjs express-rate-limit rate-limit-redis ioredis dotenv
npm install --save-dev jest supertest
</code></pre>
<h3 id="heading-starting-postgresql-and-redis-with-docker">Starting PostgreSQL and Redis with Docker</h3>
<p>Skip the local installs. One <code>docker-compose.yml</code> in the project root brings up both PostgreSQL and Redis:</p>
<pre><code class="language-yaml">services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: saas_api
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: app_password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./schema.sql:/docker-entrypoint-initdb.d/01_schema.sql

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:
</code></pre>
<p>That <code>schema.sql</code> mount runs your SQL automatically when the container first starts. No psql required.</p>
<pre><code class="language-bash">docker compose up -d
</code></pre>
<p><code>.env</code> in the project root:</p>
<pre><code class="language-plaintext">DATABASE_URL=postgresql://app_user:app_password@localhost:5432/saas_api
REDIS_URL=redis://localhost:6379
JWT_SECRET=your_random_secret_here
PORT=3000
NODE_ENV=development
</code></pre>
<p>Don't type a <code>JWT_SECRET</code> by hand. Run this to generate one:</p>
<pre><code class="language-bash">node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
</code></pre>
<p>File structure:</p>
<pre><code class="language-plaintext">nodejs-multitenant-saas-api/
├── src/
│   ├── middleware/
│   │   ├── auth.js          # JWT verification + tenant extraction
│   │   ├── rbac.js          # Role enforcement
│   │   └── rateLimiter.js   # Per-tenant rate limiting
│   ├── services/
│   │   └── auditService.js  # Append-only audit logger
│   ├── repositories/
│   │   └── projectRepo.js   # Tenant-safe DB queries
│   ├── routes/
│   │   └── projects.js      # Route handlers
│   └── utils/
│       └── token.js         # JWT token generation
├── db/
│   ├── index.js             # PostgreSQL pool
│   └── redis.js             # Redis client
├── docker-compose.yml
├── app.js
├── server.js
└── tests/
    └── tenantIsolation.test.js
</code></pre>
<h3 id="heading-boilerplate-files">Boilerplate Files</h3>
<p>There are four files the tutorial doesn't cover in detail, but the test file needs all of them to run:</p>
<pre><code class="language-javascript">// db/index.js
const { Pool } = require('pg');

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

pool.on('error', (err) =&gt; console.error('PostgreSQL error:', err.message));

module.exports = { pool };
</code></pre>
<pre><code class="language-javascript">// db/redis.js
const Redis = require('ioredis');

const redisClient = new Redis(process.env.REDIS_URL);

redisClient.on('error', (err) =&gt; console.error('Redis error:', err.message));

module.exports = { redisClient };
</code></pre>
<pre><code class="language-javascript">// app.js
require('dotenv').config();
const express = require('express');
const projectsRouter = require('./src/routes/projects');

const app = express();
app.use(express.json());

app.use('/api/projects', projectsRouter);

// Global error handler — must have 4 parameters to be recognised by Express
app.use((err, req, res, next) =&gt; {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal server error' });
});

module.exports = app;
</code></pre>
<pre><code class="language-javascript">// server.js
const app = require('./app');

const PORT = process.env.PORT || 3000;
app.listen(PORT, () =&gt; console.log(`Server running on port ${PORT}`));
</code></pre>
<p><code>bcryptjs</code> is included for a login endpoint with proper password hashing. That part isn't covered here, but the GitHub repo has a working <code>/api/auth/login</code> example.</p>
<h2 id="heading-jwt-design-for-multi-tenancy">JWT Design for Multi-Tenancy</h2>
<p>Both <code>tenantId</code> and <code>role</code> go into the JWT payload. Everything downstream reads from these two fields. Get them wrong, and nothing behaves correctly.</p>
<pre><code class="language-javascript">// Example JWT payload
{
  "userId": "usr_abc123",
  "tenantId": "ten_xyz789",
  "email": "alice@acme.com",
  "role": "TenantAdmin",
  "iat": 1720000000,
  "exp": 1720086400
}
</code></pre>
<p>The roles in order of privilege:</p>
<ul>
<li><p><strong>SuperAdmin:</strong> cross-tenant access for your internal team only</p>
</li>
<li><p><strong>TenantAdmin:</strong> full access within their tenant</p>
</li>
<li><p><strong>Member:</strong> read and write within their tenant</p>
</li>
<li><p><strong>Viewer:</strong> read-only within their tenant</p>
</li>
</ul>
<p>Generate a token (used for testing and your auth endpoint):</p>
<pre><code class="language-javascript">// src/utils/token.js
const jwt = require('jsonwebtoken');

function generateToken({ userId, tenantId, email, role }) {
  return jwt.sign(
    { userId, tenantId, email, role },
    process.env.JWT_SECRET,
    { expiresIn: '24h' }
  );
}

module.exports = { generateToken };
</code></pre>
<h2 id="heading-auth-and-rbac-middleware">Auth and RBAC Middleware</h2>
<p>The auth middleware does two things: verifies the JWT signature and extracts the tenant context into <code>req.user</code>.</p>
<p>That second part is what the entire system depends on. Every query downstream reads <code>req.user.tenantId</code>. The client has no say in what that value is. They send a token the server signed, and the server reads back what it put in.</p>
<pre><code class="language-javascript">// src/middleware/auth.js
const jwt = require('jsonwebtoken');

function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed Authorization header' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // tenantId always comes from the verified token — never req.body or req.params
    req.user = {
      userId:   decoded.userId,
      tenantId: decoded.tenantId,
      email:    decoded.email,
      role:     decoded.role,
    };

    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

module.exports = { authMiddleware };
</code></pre>
<p>The RBAC middleware is separate from auth by design. Auth runs on every route. Role enforcement only applies where a minimum role is required. You pass the allowed roles to <code>requireRole()</code> and it compares the user's level against the hierarchy. A Viewer trying to delete something hits the 403 before the handler ever runs.</p>
<pre><code class="language-javascript">// src/middleware/rbac.js
const ROLE_HIERARCHY = {
  SuperAdmin:   4,
  TenantAdmin:  3,
  Member:       2,
  Viewer:       1,
};

// requireRole('TenantAdmin') — user must be TenantAdmin or higher
function requireRole(...roles) {
  return (req, res, next) =&gt; {
    const userLevel = ROLE_HIERARCHY[req.user?.role] ?? 0;
    const requiredLevel = Math.min(...roles.map(r =&gt; ROLE_HIERARCHY[r] ?? 999));

    if (userLevel &lt; requiredLevel) {
      return res.status(403).json({
        error: 'Insufficient permissions',
        required: roles,
        current: req.user?.role,
      });
    }

    next();
  };
}

module.exports = { requireRole };
</code></pre>
<h2 id="heading-the-tenant-safe-repository-layer">The Tenant-Safe Repository Layer</h2>
<p>Isolation lives here. Every function takes <code>tenantId</code> as a required argument, pulled from <code>req.user</code> by the handler. There's no way to call these without providing a tenant scope. I've watched teams try to handle this with a URL parameter instead (<code>GET /api/projects?tenantId=xyz</code>) and call it isolated. It is not. Any client sends whatever it wants in a query string.</p>
<pre><code class="language-javascript">// src/repositories/projectRepo.js
const { pool } = require('../../db');

// List all projects for a tenant — tenantId is ALWAYS from the JWT
async function listProjects(tenantId) {
  const result = await pool.query(
    `SELECT id, name, description, created_by, created_at
     FROM projects
     WHERE tenant_id = $1
     ORDER BY created_at DESC`,
    [tenantId]
  );
  return result.rows;
}

// Get a single project — returns null if it belongs to a different tenant
// NOTE: Returns 404 (not 403) intentionally — don't reveal the resource exists
async function getProject(id, tenantId) {
  const result = await pool.query(
    `SELECT id, name, description, created_by, created_at
     FROM projects
     WHERE id = $1 AND tenant_id = $2`,
    [id, tenantId]
  );
  return result.rows[0] || null;
}

async function createProject({ tenantId, name, description, createdBy }) {
  const result = await pool.query(
    `INSERT INTO projects (tenant_id, name, description, created_by)
     VALUES ($1, $2, $3, $4)
     RETURNING *`,
    [tenantId, name, description, createdBy]
  );
  return result.rows[0];
}

async function updateProject(id, tenantId, updates) {
  const result = await pool.query(
    `UPDATE projects
     SET name = COALESCE($3, name),
         description = COALESCE($4, description),
         updated_at = NOW()
     WHERE id = $1 AND tenant_id = $2
     RETURNING *`,
    [id, tenantId, updates.name, updates.description]
  );
  return result.rows[0] || null;
}

async function deleteProject(id, tenantId) {
  const result = await pool.query(
    `DELETE FROM projects WHERE id = $1 AND tenant_id = $2 RETURNING id`,
    [id, tenantId]
  );
  return result.rows[0] || null;
}

module.exports = { listProjects, getProject, createProject, updateProject, deleteProject };
</code></pre>
<p>Notice what <code>getProject</code> does when Tenant A tries to fetch a Tenant B resource. The query runs with Tenant A's <code>tenantId</code>. The condition <code>id = $1 AND tenant_id = $2</code> matches nothing, <code>null</code> comes back, and the handler sends a <code>404</code>. Not a <code>403</code>. A 403 tells the caller the resource exists, but they can't access it, which is information they shouldn't have.</p>
<h2 id="heading-audit-logging-service">Audit Logging Service</h2>
<pre><code class="language-javascript">// src/services/auditService.js
const { pool } = require('../../db');

async function log({
  tenantId,
  userId,
  userEmail,
  userRole,          // role at time of action — roles change, log should not
  action,            // 'CREATE' | 'UPDATE' | 'DELETE' | 'VIEW'
  resource,          // table name
  resourceId = null,
  oldValues = null,
  newValues = null,
  ipAddress = null,
  userAgent = null,
}) {
  const query = `
    INSERT INTO audit_logs
      (tenant_id, user_id, user_email, user_role, action, resource,
       resource_id, old_values, new_values, ip_address, user_agent)
    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
  `;

  const values = [
    tenantId, userId, userEmail, userRole, action, resource,
    resourceId,
    oldValues  ? JSON.stringify(oldValues)  : null,
    newValues  ? JSON.stringify(newValues)  : null,
    ipAddress,
    userAgent,
  ];

  // Fire-and-forget — audit logging must never block or fail a user request
  pool.query(query, values).catch((err) =&gt; {
    console.error('[AuditService] Failed to write log:', err.message);
  });
}

module.exports = { log };
</code></pre>
<p>Capturing <code>userRole</code> at write time matters more than it looks. User roles change after the fact: someone gets demoted, a permission is revoked. If the log only records the user ID, you lose the context of what privilege they held when the action happened. Store the role at the time of the action, and you always know.</p>
<h2 id="heading-per-tenant-rate-limiting">Per-Tenant Rate Limiting</h2>
<p>IP-based rate limiting breaks down in SaaS. A corporate customer might route hundreds of users through a single NAT gateway, sharing one IP address. One heavy tenant throttles everyone else on that address.</p>
<p>I've watched teams discover this the hard way when an enterprise customer suddenly floods the API, and their other tenants start getting 429s with no explanation. Scope limits to <code>tenant_id</code> instead.</p>
<pre><code class="language-javascript">// src/middleware/rateLimiter.js
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const { redisClient } = require('../../db/redis');

// Rate limits by plan — extend as needed
const PLAN_LIMITS = {
  free:       { max: 100,  windowMs: 15 * 60 * 1000 }, // 100 req / 15 min
  pro:        { max: 500,  windowMs: 15 * 60 * 1000 }, // 500 req / 15 min
  enterprise: { max: 2000, windowMs: 15 * 60 * 1000 }, // 2000 req / 15 min
};

function createTenantRateLimiter(plan = 'free') {
  const limits = PLAN_LIMITS[plan] || PLAN_LIMITS.free;

  return rateLimit({
    windowMs: limits.windowMs,
    max: limits.max,
    // Key = tenant_id from verified JWT — NOT the IP address
    keyGenerator: (req) =&gt; `tenant:${req.user?.tenantId || req.ip}`,
    store: new RedisStore({
      sendCommand: (...args) =&gt; redisClient.call(...args),
    }),
    handler: (req, res) =&gt; {
      res.status(429).json({
        error: 'Too many requests',
        retryAfter: Math.ceil(limits.windowMs / 1000),
      });
    },
  });
}

// Default limiter for all API routes
const defaultLimiter = createTenantRateLimiter('free');

module.exports = { defaultLimiter, createTenantRateLimiter };
</code></pre>
<h2 id="heading-building-the-routes">Building the Routes</h2>
<p>This is where everything connects. Auth and rate limiting apply to the whole router. Role checks go on individual routes. The audit log fires after every write. <code>tenantId</code> never comes from the request body or URL. <code>req.user.tenantId</code> is the only source, set by the auth middleware from the verified token, so there's no path around it.</p>
<p>One practical detail for Express 4: it doesn't catch async errors automatically. Every handler wraps its logic in try/catch and passes failures to <code>next(err)</code>. Skip that and an unhandled promise rejection returns a blank 500 with no log entry and no audit trail. The comment at the top of the router is a reminder that the pattern is intentional.</p>
<pre><code class="language-javascript">// src/routes/projects.js
const express = require('express');
const { authMiddleware }  = require('../middleware/auth');
const { requireRole }     = require('../middleware/rbac');
const { defaultLimiter }  = require('../middleware/rateLimiter');
const audit               = require('../services/auditService');
const repo                = require('../repositories/projectRepo');

const router = express.Router();

// All routes require authentication
router.use(authMiddleware);
router.use(defaultLimiter);

// Express 4 does not catch async errors automatically.
// Every handler must wrap await calls in try/catch and pass errors to next().
// Without this, an unhandled promise rejection silently returns 500
// with no useful message and no audit log entry.

// GET /api/projects — list all (Viewer and above)
router.get('/', async (req, res, next) =&gt; {
  try {
    const projects = await repo.listProjects(req.user.tenantId);

    audit.log({
      tenantId:   req.user.tenantId,
      userId:     req.user.userId,
      userEmail:  req.user.email,
      userRole:   req.user.role,
      action:     'VIEW',
      resource:   'projects',
      ipAddress:  req.ip,
      userAgent:  req.headers['user-agent'],
    });

    res.json(projects);
  } catch (err) {
    next(err);
  }
});

// GET /api/projects/:id — single project (Viewer and above)
router.get('/:id', async (req, res, next) =&gt; {
  try {
    const project = await repo.getProject(req.params.id, req.user.tenantId);
    if (!project) return res.status(404).json({ error: 'Not found' });
    res.json(project);
  } catch (err) {
    next(err);
  }
});

// POST /api/projects — create (Member and above)
router.post('/', requireRole('Member', 'TenantAdmin', 'SuperAdmin'), async (req, res, next) =&gt; {
  try {
    const { name, description } = req.body;
    if (!name) return res.status(400).json({ error: 'name is required' });

    const project = await repo.createProject({
      tenantId:    req.user.tenantId,
      name,
      description,
      createdBy:   req.user.userId,
    });

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'CREATE',
      resource:    'projects',
      resourceId:  project.id,
      newValues:   project,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.status(201).json(project);
  } catch (err) {
    next(err);
  }
});

// PUT /api/projects/:id — update (Member and above)
router.put('/:id', requireRole('Member', 'TenantAdmin', 'SuperAdmin'), async (req, res, next) =&gt; {
  try {
    const oldProject = await repo.getProject(req.params.id, req.user.tenantId);
    if (!oldProject) return res.status(404).json({ error: 'Not found' });

    const updated = await repo.updateProject(req.params.id, req.user.tenantId, req.body);

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'UPDATE',
      resource:    'projects',
      resourceId:  req.params.id,
      oldValues:   oldProject,
      newValues:   updated,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.json(updated);
  } catch (err) {
    next(err);
  }
});

// DELETE /api/projects/:id — TenantAdmin and above only
router.delete('/:id', requireRole('TenantAdmin', 'SuperAdmin'), async (req, res, next) =&gt; {
  try {
    const project = await repo.getProject(req.params.id, req.user.tenantId);
    if (!project) return res.status(404).json({ error: 'Not found' });

    await repo.deleteProject(req.params.id, req.user.tenantId);

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'DELETE',
      resource:    'projects',
      resourceId:  req.params.id,
      oldValues:   project,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.json({ deleted: true });
  } catch (err) {
    next(err);
  }
});

module.exports = router;
</code></pre>
<h2 id="heading-testing-tenant-isolation">Testing Tenant Isolation</h2>
<p>Skip the isolation tests and you're flying blind. The application keeps running, nothing throws an error, but two customers are reading each other's data.</p>
<p>I've watched this sit undetected in production for months because nothing actually broke. The wrong data just showed up quietly. Automated tests on every pull request are the only reliable way to catch it early.</p>
<pre><code class="language-javascript">// tests/tenantIsolation.test.js
require('dotenv').config();  // must be first — loads DATABASE_URL and REDIS_URL
const request = require('supertest');
const app     = require('../app');
const { generateToken } = require('../src/utils/token');
const { pool }        = require('../db');
const { redisClient } = require('../db/redis');

// Test fixture: two isolated tenants, one project in Tenant B
async function seedTestData() {
  // Clean up from any previous run to avoid unique-constraint failures
  await pool.query(`DELETE FROM projects WHERE name LIKE 'TEST-%'`);
  await pool.query(`DELETE FROM tenants WHERE name IN ('Tenant A', 'Tenant B')`);

  const tenantA = (await pool.query(
    `INSERT INTO tenants (name, plan) VALUES ('Tenant A', 'pro') RETURNING id`
  )).rows[0].id;

  const tenantB = (await pool.query(
    `INSERT INTO tenants (name, plan) VALUES ('Tenant B', 'pro') RETURNING id`
  )).rows[0].id;

  const userA = (await pool.query(
    `INSERT INTO users (tenant_id, email, role) VALUES ($1, 'usera@a.com', 'Member') RETURNING id`,
    [tenantA]
  )).rows[0].id;

  // userB owns the project in Tenant B — satisfies the created_by FK constraint
  const userB = (await pool.query(
    `INSERT INTO users (tenant_id, email, role) VALUES ($1, 'userb@b.com', 'Member') RETURNING id`,
    [tenantB]
  )).rows[0].id;

  const projectB = (await pool.query(
    `INSERT INTO projects (tenant_id, name, created_by)
     VALUES ($1, 'TEST-Secret Project', $2) RETURNING id`,
    [tenantB, userB]
  )).rows[0].id;

  return { tenantA, tenantB, userA, projectB };
}

describe('Tenant Isolation', () =&gt; {
  let data;

  beforeAll(async () =&gt; {
    data = await seedTestData();
  });

  afterAll(async () =&gt; {
    await pool.query(`DELETE FROM tenants WHERE name IN ('Tenant A', 'Tenant B')`);
    await pool.end();
    await redisClient.quit();  // close Redis connection so Jest exits cleanly
  });

  test('Tenant A user cannot read Tenant B project', async () =&gt; {
    const token = generateToken({
      userId:   data.userA,
      tenantId: data.tenantA,   // ← Tenant A token
      email:    'usera@a.com',
      role:     'Member',
    });

    const res = await request(app)
      .get(`/api/projects/${data.projectB}`)  // ← Tenant B's project ID
      .set('Authorization', `Bearer ${token}`);

    // Must be 404, not 200 or 403
    expect(res.status).toBe(404);
  });

  test('Tenant A user cannot list Tenant B projects', async () =&gt; {
    const token = generateToken({
      userId:   data.userA,
      tenantId: data.tenantA,
      email:    'usera@a.com',
      role:     'TenantAdmin',
    });

    const res = await request(app)
      .get('/api/projects')
      .set('Authorization', `Bearer ${token}`);

    expect(res.status).toBe(200);
    // Response must contain zero Tenant B projects
    const names = res.body.map(p =&gt; p.name);
    expect(names).not.toContain('TEST-Secret Project');
  });

  test('Viewer cannot delete a project', async () =&gt; {
    const token = generateToken({
      userId:   data.userA,
      tenantId: data.tenantA,
      email:    'usera@a.com',
      role:     'Viewer',         // ← Viewer role
    });

    const res = await request(app)
      .delete(`/api/projects/${data.projectB}`)
      .set('Authorization', `Bearer ${token}`);

    expect(res.status).toBe(403);
  });
});
</code></pre>
<p>Run the tests:</p>
<pre><code class="language-bash">npm test
</code></pre>
<p>Three tests, three boundaries confirmed. Wire these into CI so they run on every pull request. A future refactor that quietly drops the <code>tenant_id</code> filter will get caught before it ships.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<h3 id="heading-tenant-a-can-see-tenant-bs-data">Tenant A can see Tenant B's data</h3>
<p>One query is missing the <code>AND tenant_id = $N</code> clause. Search every repository file for <code>SELECT</code> statements and check each one. It's almost always this.</p>
<h3 id="heading-403-forbidden-on-a-route-that-should-be-accessible"><code>403 Forbidden</code> on a route that should be accessible</h3>
<p>The role string in the JWT doesn't match what <code>requireRole()</code> is checking. Check the exact string in the token payload. <code>'member'</code> and <code>'Member'</code> aren't the same thing. Paste your token into jwt.io and look at the role field directly.</p>
<h3 id="heading-rate-limiter-isnt-working">Rate limiter isn't working</h3>
<p>Redis is probably not connected. Log <code>redisClient.status</code> before the server starts. If it's not <code>ready</code>, the limiter has fallen back to in-memory, which means restarts reset all counters and tenant-scoped limiting stops working.</p>
<h3 id="heading-audit-log-table-growing-very-large">Audit log table growing very large</h3>
<p>Expected behaviour. Audit tables grow, that's the point. Once it gets large, ship rows older than a year to S3 or Azure Blob and keep querying against a smaller hot table. Most compliance requirements want at least 12 months of accessible logs anyway. Just don't DELETE from the table itself.</p>
<h3 id="heading-jwtverify-throws-jsonwebtokenerror-invalid-signature"><code>jwt.verify</code> throws <code>JsonWebTokenError: invalid signature</code></h3>
<p>The secret that signed the token doesn't match <code>JWT_SECRET</code> in the environment where you're verifying it. This comes up most when switching between environments or when a second service has a different value in its <code>.env</code>. Every service that calls <code>jwt.verify</code> needs the exact same secret. Copy it across, don't retype it.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>The system you've built: row-level isolation in the repository, role checks before the handler runs, an audit table the app can't touch, and rate limits per tenant. That's the whole thing.</p>
<p>The tests are what I see dropped most often. Teams build the isolation, ship it, and never write something that actually proves cross-tenant data can't leak. Then a query gets refactored six months later and the <code>tenant_id</code> filter quietly disappears. CI catches it. Manual code review rarely does.</p>
<p>Schema-per-tenant comes up eventually if your product grows large enough. But not at the start. Row-level isolation handles more scale than most teams will ever hit, and it costs a fraction of the operational overhead.</p>
<p>The full working code is available on GitHub: <a href="https://github.com/ziaongit/nodejs-multitenant-saas-api">nodejs-multitenant-saas-api</a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Implement Role-Based Access Control in a Node.js REST API with JWT ]]>
                </title>
                <description>
                    <![CDATA[ The first time I built an API without thinking about roles, I gave every logged-in user the same access. It worked fine until a regular user accidentally hit a delete endpoint and wiped test data. Tha ]]>
                </description>
                <link>https://www.freecodecamp.org/news/role-based-access-control-nodejs-rest-api-jwt/</link>
                <guid isPermaLink="false">6a4fb4570140649a4367b476</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Thu, 09 Jul 2026 14:46:47 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d742efbd-8170-4fb6-8851-1f7c6ef9125e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The first time I built an API without thinking about roles, I gave every logged-in user the same access. It worked fine until a regular user accidentally hit a delete endpoint and wiped test data. That was the day I actually sat down and learned RBAC properly.</p>
<p>Role-Based Access Control sounds fancy, but the idea is simple: what you can do depends on <em>who you are</em>, not just <em>that you're logged in</em>. An admin deletes users. An editor creates posts. A regular user just reads. Same app, completely different experience depending on who's asking.</p>
<p>That's what we're building here. A REST API with three roles: JWT to carry those roles on every request, and a pair of middleware functions that check permissions before your route handlers even run. There's no database hit per request, and no if/else soup in your business logic.</p>
<p>By the end, you'll have three working roles (<code>admin</code>, <code>editor</code>, <code>user</code>) each locked to their own endpoints. More importantly, the pattern is transferable: once it clicks, you'll wire it into your next project without needing a tutorial.</p>
<p><strong>Full source code on GitHub:</strong> <a href="https://github.com/ziaongit/nodejs-rbac-jwt-api">github.com/ziaongit/nodejs-rbac-jwt-api</a></p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-learn">What You'll Learn</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-well-build">What We'll Build</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-setting-up-the-in-memory-data-store">Setting Up the In-Memory Data Store</a></p>
</li>
<li><p><a href="#heading-building-the-auth-routes">Building the Auth Routes</a></p>
</li>
<li><p><a href="#heading-building-the-rbac-middleware">Building the RBAC Middleware</a></p>
</li>
<li><p><a href="#heading-building-the-protected-routes">Building the Protected Routes</a></p>
</li>
<li><p><a href="#heading-putting-it-all-together">Putting It All Together</a></p>
</li>
<li><p><a href="#heading-testing-the-api">Testing the API</a></p>
</li>
<li><p><a href="#heading-key-takeaways">Key Takeaways</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-youll-learn">What You'll Learn</h2>
<ul>
<li><p>What RBAC is and how it differs from basic authentication</p>
</li>
<li><p>How to embed roles in JWT payloads</p>
</li>
<li><p>How to write reusable Express middleware for token verification and role checking</p>
</li>
<li><p>How to protect API routes based on user roles</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js (v18+) installed</p>
</li>
<li><p>Basic knowledge of Express.js</p>
</li>
<li><p>Familiarity with how JWTs work (we'll cover the relevant parts)</p>
</li>
<li><p>npm installed</p>
</li>
</ul>
<h2 id="heading-what-well-build">What We'll Build</h2>
<p>We'll build a REST API for a simple content management system with three user roles:</p>
<table>
<thead>
<tr>
<th>Role</th>
<th>Permissions</th>
</tr>
</thead>
<tbody><tr>
<td><code>user</code></td>
<td>Read content</td>
</tr>
<tr>
<td><code>editor</code></td>
<td>Read + create content</td>
</tr>
<tr>
<td><code>admin</code></td>
<td>Full access — read, create, delete content, manage users</td>
</tr>
</tbody></table>
<p>The API will expose these endpoints:</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>Access</th>
</tr>
</thead>
<tbody><tr>
<td>POST</td>
<td>/api/auth/register</td>
<td>Public</td>
</tr>
<tr>
<td>POST</td>
<td>/api/auth/login</td>
<td>Public</td>
</tr>
<tr>
<td>GET</td>
<td>/api/content</td>
<td>user, editor, admin</td>
</tr>
<tr>
<td>POST</td>
<td>/api/content</td>
<td>editor, admin</td>
</tr>
<tr>
<td>DELETE</td>
<td>/api/content/:id</td>
<td>admin only</td>
</tr>
<tr>
<td>GET</td>
<td>/api/admin/users</td>
<td>admin only</td>
</tr>
</tbody></table>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Create a new folder and initialize the project:</p>
<pre><code class="language-bash">mkdir nodejs-rbac-jwt-api
cd nodejs-rbac-jwt-api
npm init -y
</code></pre>
<p>Install the dependencies:</p>
<pre><code class="language-bash">npm install express jsonwebtoken bcryptjs dotenv
npm install --save-dev nodemon
</code></pre>
<p>Here's what each package does:</p>
<ul>
<li><p><strong>express</strong>: web framework for building the API</p>
</li>
<li><p><strong>jsonwebtoken</strong>: creates and verifies JWTs</p>
</li>
<li><p><strong>bcryptjs</strong>: securely hashes passwords</p>
</li>
<li><p><strong>dotenv</strong>: reads your <code>.env</code> file so you're not hardcoding secrets in your source code</p>
</li>
</ul>
<p>Update <code>package.json</code> to add start scripts:</p>
<pre><code class="language-json">"scripts": {
  "start": "node src/app.js",
  "dev": "nodemon src/app.js"
}
</code></pre>
<p>Create the project structure:</p>
<pre><code class="language-plaintext">nodejs-rbac-jwt-api/
├── src/
│   ├── middleware/
│   │   └── auth.js
│   ├── routes/
│   │   ├── auth.js
│   │   ├── content.js
│   │   └── admin.js
│   ├── data/
│   │   └── users.js
│   └── app.js
├── .env
├── .env.example
└── package.json
</code></pre>
<p>Create your <code>.env</code> file:</p>
<pre><code class="language-plaintext">JWT_SECRET=your_super_secret_key_change_this_in_production
PORT=3000
</code></pre>
<p><strong>Important:</strong> Never commit your <code>.env</code> file to version control. Add it to <code>.gitignore</code>.</p>
<h2 id="heading-setting-up-the-in-memory-data-store">Setting Up the In-Memory Data Store</h2>
<p>We don't have a database here, just an array in memory. The point was to keep the focus on RBAC, not spend half the tutorial on database config. In a real project, swap the array for whatever database you're already using.</p>
<p>Create <code>src/data/users.js</code>:</p>
<pre><code class="language-javascript">// In-memory users store
// In production, replace this with a real database (MongoDB, PostgreSQL, etc.)
const users = [];

const findUserByEmail = (email) =&gt; users.find((u) =&gt; u.email === email);
const findUserById = (id) =&gt; users.find((u) =&gt; u.id === id);
const createUser = (user) =&gt; {
  users.push(user);
  return user;
};
const getAllUsers = () =&gt; users.map(({ password, ...user }) =&gt; user);

module.exports = { findUserByEmail, findUserById, createUser, getAllUsers };
</code></pre>
<p>One thing worth noting: <code>getAllUsers</code> uses destructuring to drop the password before returning anything. Never send password fields in API responses, even hashed ones.</p>
<h2 id="heading-building-the-auth-routes">Building the Auth Routes</h2>
<p>The auth routes handle registration and login. Login is where roles first enter the picture — we embed the user's role directly into the JWT payload.</p>
<p>Create <code>src/routes/auth.js</code>:</p>
<pre><code class="language-javascript">const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { findUserByEmail, createUser } = require('../data/users');

const router = express.Router();

// POST /api/auth/register
router.post('/register', async (req, res) =&gt; {
  const { name, email, password, role } = req.body;

  if (!name || !email || !password) {
    return res.status(400).json({ message: 'Name, email, and password are required' });
  }

  if (findUserByEmail(email)) {
    return res.status(409).json({ message: 'Email already registered' });
  }

  // Only allow valid roles — default to 'user' if none provided
  const validRoles = ['user', 'editor', 'admin'];
  const assignedRole = validRoles.includes(role) ? role : 'user';

  const hashedPassword = await bcrypt.hash(password, 10);

  const newUser = {
    id: Date.now().toString(),
    name,
    email,
    password: hashedPassword,
    role: assignedRole,
  };

  createUser(newUser);

  res.status(201).json({
    message: 'User registered successfully',
    user: {
      id: newUser.id,
      name: newUser.name,
      email: newUser.email,
      role: newUser.role,
    },
  });
});

// POST /api/auth/login
router.post('/login', async (req, res) =&gt; {
  const { email, password } = req.body;

  if (!email || !password) {
    return res.status(400).json({ message: 'Email and password are required' });
  }

  const user = findUserByEmail(email);
  if (!user) {
    return res.status(401).json({ message: 'Invalid credentials' });
  }

  const isMatch = await bcrypt.compare(password, user.password);
  if (!isMatch) {
    return res.status(401).json({ message: 'Invalid credentials' });
  }

  // Issue JWT — embed role in the payload
  const token = jwt.sign(
    {
      id: user.id,
      email: user.email,
      role: user.role,   // ← This is the key part for RBAC
    },
    process.env.JWT_SECRET,
    { expiresIn: '24h' }
  );

  res.json({
    message: 'Login successful',
    token,
  });
});

module.exports = router;
</code></pre>
<p>The most important line is the JWT payload:</p>
<pre><code class="language-javascript">jwt.sign({ id, email, role }, process.env.JWT_SECRET, { expiresIn: '24h' })
</code></pre>
<p>By embedding <code>role</code> in the token, every subsequent request carries the user's permissions without requiring a database lookup. The server just verifies the token and reads the role from the payload.</p>
<h2 id="heading-building-the-rbac-middleware">Building the RBAC Middleware</h2>
<p>This is the core of the system. We need two separate middleware functions:</p>
<ol>
<li><p><code>verifyToken</code> confirms the JWT is valid and attaches the decoded payload to <code>req.user</code></p>
</li>
<li><p><code>checkRole</code> confirms the user has the required role for a specific route</p>
</li>
</ol>
<p>Keeping them separate gives you flexibility. Some routes only need authentication. Others need both authentication and a specific role.</p>
<p>Create <code>src/middleware/auth.js</code>:</p>
<pre><code class="language-javascript">const jwt = require('jsonwebtoken');

// Middleware 1: Verify the JWT token
const verifyToken = (req, res, next) =&gt; {
  const authHeader = req.headers['authorization'];
  const token = authHeader &amp;&amp; authHeader.split(' ')[1]; // Expects: Bearer &lt;token&gt;

  if (!token) {
    return res.status(401).json({ message: 'Access denied. No token provided.' });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded; // Attach decoded payload (including role) to request
    next();
  } catch (err) {
    return res.status(403).json({ message: 'Invalid or expired token.' });
  }
};

// Middleware 2: Check if user has one of the required roles
const checkRole = (...allowedRoles) =&gt; {
  return (req, res, next) =&gt; {
    if (!req.user) {
      return res.status(401).json({ message: 'Not authenticated.' });
    }

    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({
        message: `Access denied. Required role: ${allowedRoles.join(' or ')}. Your role: ${req.user.role}`,
      });
    }

    next();
  };
};

module.exports = { verifyToken, checkRole };
</code></pre>
<p><code>checkRole</code> uses a rest parameter (<code>...allowedRoles</code>) so you can pass in one or multiple roles:</p>
<pre><code class="language-javascript">checkRole('admin')                  // only admin
checkRole('editor', 'admin')        // editor or admin
checkRole('user', 'editor', 'admin') // all roles
</code></pre>
<p>This makes route definitions clean and readable — the permissions are visible right at the route level.</p>
<h2 id="heading-building-the-protected-routes">Building the Protected Routes</h2>
<p>Now let's wire up routes that use the middleware.</p>
<p>Create <code>src/routes/content.js</code>:</p>
<pre><code class="language-javascript">const express = require('express');
const { verifyToken, checkRole } = require('../middleware/auth');

const router = express.Router();

// In-memory content store
const content = [
  { id: '1', title: 'Getting Started with Node.js', author: 'admin' },
  { id: '2', title: 'Express Middleware Explained', author: 'editor' },
];

// GET /api/content — all authenticated users
router.get('/', verifyToken, checkRole('user', 'editor', 'admin'), (req, res) =&gt; {
  res.json({ content });
});

// POST /api/content — editors and admins only
router.post('/', verifyToken, checkRole('editor', 'admin'), (req, res) =&gt; {
  const { title } = req.body;

  if (!title) {
    return res.status(400).json({ message: 'Title is required' });
  }

  const newItem = {
    id: Date.now().toString(),
    title,
    author: req.user.email,
  };

  content.push(newItem);
  res.status(201).json({ message: 'Content created', item: newItem });
});

// DELETE /api/content/:id — admin only
router.delete('/:id', verifyToken, checkRole('admin'), (req, res) =&gt; {
  const index = content.findIndex((c) =&gt; c.id === req.params.id);

  if (index === -1) {
    return res.status(404).json({ message: 'Content not found' });
  }

  content.splice(index, 1);
  res.json({ message: 'Content deleted successfully' });
});

module.exports = router;
</code></pre>
<p>Notice how readable each route is:</p>
<pre><code class="language-javascript">router.delete('/:id', verifyToken, checkRole('admin'), handler)
</code></pre>
<p>You can understand the access control without reading the handler body. This is one of the key advantages of middleware-based RBAC: permissions live at the routing layer, not buried in business logic.</p>
<p>Create <code>src/routes/admin.js</code>:</p>
<pre><code class="language-javascript">const express = require('express');
const { verifyToken, checkRole } = require('../middleware/auth');
const { getAllUsers } = require('../data/users');

const router = express.Router();

// GET /api/admin/users — admin only
router.get('/users', verifyToken, checkRole('admin'), (req, res) =&gt; {
  res.json({ users: getAllUsers() });
});

module.exports = router;
</code></pre>
<h2 id="heading-putting-it-all-together">Putting It All Together</h2>
<p>Create <code>src/app.js</code>:</p>
<pre><code class="language-javascript">require('dotenv').config();
const express = require('express');

const authRoutes = require('./routes/auth');
const contentRoutes = require('./routes/content');
const adminRoutes = require('./routes/admin');

const app = express();

app.use(express.json());

// Routes
app.use('/api/auth', authRoutes);
app.use('/api/content', contentRoutes);
app.use('/api/admin', adminRoutes);

// Health check
app.get('/', (req, res) =&gt; {
  res.json({ message: 'RBAC API is running' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () =&gt; {
  console.log(`Server running on port ${PORT}`);
});
</code></pre>
<h2 id="heading-testing-the-api">Testing the API</h2>
<p>Start the server:</p>
<pre><code class="language-bash">npm run dev
</code></pre>
<h3 id="heading-step-1-register-users-with-different-roles">Step 1: Register Users with Different Roles</h3>
<p>Register an admin:</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Admin User", "email": "admin@example.com", "password": "password123", "role": "admin"}'
</code></pre>
<p>Register an editor:</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Editor User", "email": "editor@example.com", "password": "password123", "role": "editor"}'
</code></pre>
<p>Register a regular user (no role specified — defaults to <code>user</code>):</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Regular User", "email": "user@example.com", "password": "password123"}'
</code></pre>
<h3 id="heading-step-2-log-in-and-get-a-token">Step 2: Log in and Get a Token</h3>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "password123"}'
</code></pre>
<p>You'll get a response like:</p>
<pre><code class="language-json">{
  "message": "Login successful",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
</code></pre>
<p>Copy the token.</p>
<h3 id="heading-step-3-test-role-based-access">Step 3: Test Role-based Access</h3>
<p><strong>Read content as a regular user (should succeed):</strong></p>
<pre><code class="language-bash">curl http://localhost:3000/api/content \
  -H "Authorization: Bearer YOUR_TOKEN_HERE"
</code></pre>
<p><strong>Try creating content as a regular user (should fail — 403):</strong></p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/content \
  -H "Authorization: Bearer YOUR_TOKEN_HERE" \
  -H "Content-Type: application/json" \
  -d '{"title": "New Article"}'
</code></pre>
<p>Response:</p>
<pre><code class="language-json">{
  "message": "Access denied. Required role: editor or admin. Your role: user"
}
</code></pre>
<p>Now log in as an editor and try the same POST request. It succeeds. Log in as admin and try the DELETE route. Only the admin token will work.</p>
<h3 id="heading-step-4-decode-the-jwt-to-see-the-role">Step 4: Decode the JWT to See the Role</h3>
<p>You can paste any token into <a href="https://jwt.io">jwt.io</a> to inspect the payload. You'll see something like:</p>
<pre><code class="language-json">{
  "id": "1720300000000",
  "email": "admin@example.com",
  "role": "admin",
  "iat": 1720300000,
  "exp": 1720386400
}
</code></pre>
<p>The <code>role</code> field is exactly what <code>checkRole</code> reads on every protected request.</p>
<h2 id="heading-key-takeaways">Key Takeaways</h2>
<p>Roles live in the JWT payload. The role travels with the token — no extra DB call needed every time someone hits a protected route. It gets embedded at login and verified cryptographically on each request.</p>
<p>Middleware is composable. <code>verifyToken</code> and <code>checkRole</code> are separate, reusable functions. You can chain them on any route in any combination.</p>
<p>Permissions are visible at the route level. <code>router.delete('/:id', verifyToken, checkRole('admin'), handler)</code> tells you everything about access control before you even read the handler.</p>
<p><strong>Before you ship this to production:</strong></p>
<ul>
<li><p>The in-memory array was just to keep this tutorial focused — replace it with a real database before anything goes near production. A server restart wipes all your users right now.</p>
</li>
<li><p>That 24h token expiry is too long. Cut it to 15 minutes and add refresh token rotation. A stolen token becomes useless fast.</p>
</li>
<li><p>Re-validate roles from the DB on sensitive operations. A role change won't reflect in an existing token until it expires</p>
</li>
<li><p>HTTPS, always</p>
</li>
<li><p>If your permission logic grows beyond "check a role", look at <a href="https://casl.js.org/">casl</a>. It handles attribute-level rules cleanly</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The core of it fits in two middleware functions and a JWT payload. I've used this same pattern across several projects. And once you've built it yourself, you'll start spotting it everywhere, because almost every multi-user app needs some version of it.</p>
<p><strong>Full source code on GitHub:</strong> <a href="https://github.com/ziaongit/nodejs-rbac-jwt-api">github.com/ziaongit/nodejs-rbac-jwt-api</a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Secure-by-Default Node.js APIs ]]>
                </title>
                <description>
                    <![CDATA[ Most security problems I've shipped in my career weren't exotic. They weren't nation-state attacks or clever zero-days. They were boring. A missing limit here, a forgotten timeout there, a string comp ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-secure-by-default-node-js-apis/</link>
                <guid isPermaLink="false">6a3c3fc702ebd10f875ab988</guid>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Devlin Duldulao ]]>
                </dc:creator>
                <pubDate>Wed, 24 Jun 2026 20:36:23 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/edebad91-82e3-4d67-b136-bbb99859a393.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most security problems I've shipped in my career weren't exotic. They weren't nation-state attacks or clever zero-days. They were boring. A missing limit here, a forgotten timeout there, a string comparison that leaked a secret one millisecond at a time.</p>
<p>The boring stuff is what gets you, because the boring stuff is what everyone agrees to fix "later," and later has a way of never arriving.</p>
<p>My favorite personal example (favorite in the way a scar is your favorite) was an internal API that compared an access token with a plain equality check and had no limit on request size. It ran fine for a year. It ran fine right up until someone curious discovered they could both fingerprint the token comparison and post a body large enough to make the server sweat.</p>
<p>Neither bug was sophisticated. Both would have been a complete non-event if something had simply refused to let me do the wrong thing in the first place.</p>
<p>This tutorial shows you how to add practical guardrails around every HTTP API you build, regardless of framework. You'll write them by hand, in plain Node.js, with no dependencies. This will let you see exactly what each one does and why.</p>
<p>By the end, you'll have a small server that survives a lot more contact with the public internet than the version most of us shipped early in our careers.</p>
<p>This tutorial uses plain JavaScript so anyone can copy and run it. If you use TypeScript, you can add types afterward. You need Node 22 or newer, a basic understanding of HTTP requests and responses, and a terminal for testing the examples.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-how-to-start-with-the-naive-server">How to Start with the Naïve Server</a></p>
</li>
<li><p><a href="#heading-how-to-limit-the-request-body">How to Limit the Request Body</a></p>
</li>
<li><p><a href="#heading-how-to-time-out-slow-requests">How to Time Out Slow Requests</a></p>
</li>
<li><p><a href="#heading-how-to-parse-json-safely-and-block-prototype-pollution">How to Parse JSON Safely and Block Prototype Pollution</a></p>
</li>
<li><p><a href="#heading-how-to-set-security-headers-on-every-response">How to Set Security Headers on Every Response</a></p>
</li>
<li><p><a href="#heading-how-to-compare-secrets-in-constant-time">How to Compare Secrets in Constant Time</a></p>
</li>
<li><p><a href="#heading-how-to-validate-input-as-a-gate-not-a-suggestion">How to Validate Input as a Gate, Not a Suggestion</a></p>
</li>
<li><p><a href="#heading-how-to-fail-without-leaking-and-log-so-you-can-see-it">How to Fail Without Leaking and Log So You Can See It</a></p>
</li>
<li><p><a href="#heading-how-to-put-it-all-together">How to Put It All Together</a></p>
</li>
<li><p><a href="#heading-how-to-handle-cors-correctly">How to Handle CORS Correctly</a></p>
</li>
<li><p><a href="#heading-what-this-tutorial-doesnt-cover">What This Tutorial Doesn't Cover</a></p>
</li>
<li><p><a href="#heading-why-defaults-beat-checklists">Why Defaults Beat Checklists</a></p>
</li>
<li><p><a href="#heading-an-honest-note-on-frameworks">An Honest Note on Frameworks</a></p>
</li>
<li><p><a href="#heading-the-takeaway-checklist">The Takeaway Checklist</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 22 or newer</p>
</li>
<li><p>Basic familiarity with HTTP requests and responses</p>
</li>
<li><p>A terminal and curl, Postman, or a similar client</p>
</li>
</ul>
<p>This isn't a guide to making your API unhackable. It's a practical guide to avoiding the easy attacks and building safer defaults.</p>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>A plain Node.js API with request size limits, request timeouts, safe JSON parsing, security headers, timing-safe secret comparison, validation, and error handling.</p>
<h2 id="heading-how-to-start-with-the-naive-server">How to Start with the Naïve Server</h2>
<p>Here's the kind of server I wrote when I was younger and braver and wrong. It reads a JSON body and echoes it back. Pretend it's the start of a real API.</p>
<pre><code class="language-ts">import http from "node:http";

const server = http.createServer((req, res) =&gt; {
  let body = "";
  req.on("data", (chunk) =&gt; (body += chunk));
  req.on("end", () =&gt; {
    const data = JSON.parse(body || "{}");
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ youSent: data }));
  });
});

server.listen(3000, () =&gt; console.log("listening on http://localhost:3000"));
</code></pre>
<p>It works. You can curl it and it answers. It's also a small disaster, and here's the incomplete list of why:</p>
<ul>
<li><p>It reads an unbounded body into memory. Send it a few gigabytes and you have a denial-of-service with no effort.</p>
</li>
<li><p><code>JSON.parse</code> throws on bad input, which here means an uncaught exception that can take the process down.</p>
</li>
<li><p>There's no timeout. A client that sends one byte per minute can hold a connection hostage.</p>
</li>
<li><p>It sets no security headers and happily advertises that it's a Node server.</p>
</li>
<li><p>It parses untrusted JSON straight into an object with no checks, which opens the door to prototype pollution downstream.</p>
</li>
</ul>
<p>You're going to fix each of these issues. The fixes are small. The point is to make them habits, not heroics.</p>
<h2 id="heading-how-to-limit-the-request-body">How to Limit the Request Body</h2>
<p>The first rule of accepting input from strangers is to decide, in advance, how much input you're willing to accept. If you don't set a limit, the limit is "however much RAM the server has," and someone will find that out for you.</p>
<p>There are two layers here. The first is the <code>Content-Length</code> header, which the client sends to declare how big the body is. You can reject early based on it. But you must never trust it alone, because a client can lie or simply not send it.</p>
<p>The real defense is to count bytes as they stream in and stop the moment they cross your line.</p>
<pre><code class="language-ts">const MAX_BODY_BYTES = 100 * 1024; // 100 KB is plenty for most JSON APIs

function readBody(req, limit = MAX_BODY_BYTES) {
  return new Promise((resolve, reject) =&gt; {
    // Cheap early rejection if the client is honest about being too big.
    const declared = Number(req.headers["content-length"]);
    if (Number.isFinite(declared) &amp;&amp; declared &gt; limit) {
      reject(httpError(413, "Payload too large"));
      return;
    }

    let size = 0;
    const chunks = [];

    req.on("data", (chunk) =&gt; {
      size += chunk.length;
      if (size &gt; limit) {
        reject(httpError(413, "Payload too large"));
        req.destroy(); // stop reading; we are done with this client
        return;
      }
      chunks.push(chunk);
    });

    req.on("end", () =&gt; resolve(Buffer.concat(chunks)));
    req.on("error", reject);
  });
}

function httpError(statusCode, message) {
  return Object.assign(new Error(message), { statusCode });
}
</code></pre>
<p>A few things worth noticing here. You accumulate <code>Buffer</code> chunks and only join them at the end, rather than concatenating strings, because string concatenation forces an early decode and can mangle multibyte UTF-8 characters that happen to land on a chunk boundary.</p>
<p>You also call <code>req.destroy()</code> as soon as you cross the limit, so you don't keep pulling bytes you've already decided to refuse.</p>
<p>Pick a limit that matches the route. A JSON API that creates a user doesn't need a 50 MB body. A file upload endpoint is a different conversation, and there you would stream to disk or object storage instead of buffering in memory at all. The mistake is having no limit, not having the wrong one.</p>
<h2 id="heading-how-to-time-out-slow-requests">How to Time Out Slow Requests</h2>
<p>Once you have a body limit, the next trick an attacker reaches for is to be slow instead of large. This is the family of attacks named after slowloris, a sad-looking primate that moves very slowly, which is rude to the animal but accurate about the attack.</p>
<p>The idea is to open many connections and feed them bytes at a glacial pace, never finishing, so the server keeps each one alive waiting politely. Do that enough times and you've exhausted the connection pool without sending anything that looks malicious.</p>
<p>Node has built-in defenses for this, and the defaults are generous and worth tightening for an API.</p>
<pre><code class="language-ts">const server = http.createServer(handler);

// Total time allowed to receive the entire request (headers + body).
server.requestTimeout = 30_000; // 30 seconds

// Time allowed to receive just the headers. Slowloris lives here.
server.headersTimeout = 10_000; // 10 seconds

// Idle socket timeout: kill connections that go quiet.
server.setTimeout(60_000);
</code></pre>
<p>Those three lines handle the network layer. But there's a second kind of slow: your own handler. A database query that hangs, an outbound call to a third party that never answers, a regular expression that decided to think about its life choices. You want a ceiling on how long a single request is allowed to occupy a worker, and you want to be able to cancel the work when that ceiling is hit.</p>
<p>The modern tool for cancellation in Node is <code>AbortController</code>. Here's a small wrapper that gives every handler a deadline and a signal it can pass down to anything that supports cancellation, like <code>fetch</code>.</p>
<pre><code class="language-ts">function withTimeout(handler, ms = 15_000) {
  return async (req, res) =&gt; {
    const controller = new AbortController();
    const timer = setTimeout(() =&gt; controller.abort(), ms);
    try {
      await handler(req, res, controller.signal);
    } finally {
      clearTimeout(timer);
    }
  };
}
</code></pre>
<p>Now a handler can do <code>await fetch(url, { signal })</code> and the request gets cut off if it blows the deadline, instead of camping on a worker forever.</p>
<p>The discipline to learn here is that any time you talk to something outside your process, you give it a deadline. Networks fail in the most boring way possible: by hanging, not by erroring. A timeout turns a hang into a clean error you can handle.</p>
<h2 id="heading-how-to-parse-json-safely-and-block-prototype-pollution">How to Parse JSON Safely and Block Prototype Pollution</h2>
<p>This is the one people skip because it sounds theoretical, and then it shows up in a CVE with their stack in it.</p>
<p>First, the easy half. <code>JSON.parse</code> throws a <code>SyntaxError</code> on malformed input. In the naïve server, that throw was uncaught and could crash the process. So we wrap parsing and turn a parse failure into a clean 400.</p>
<pre><code class="language-ts">function parseJson(buffer) {
  if (buffer.length === 0) return {};
  let text = buffer.toString("utf8");
  try {
    return JSON.parse(text, reviver);
  } catch {
    throw httpError(400, "Invalid JSON body");
  }
}
</code></pre>
<p>Now the interesting half: that <code>reviver</code> argument. Prototype pollution is an attack where a request payload reaches up and modifies <code>Object.prototype</code>, the object that almost every object in your program inherits from. If an attacker can set a property there, they can set it on effectively everything at once.</p>
<p>It's easier to believe once you see it. Here's a recursive merge function, the kind people write all the time to apply updates onto an existing record:</p>
<pre><code class="language-ts">function merge(target, source) {
  for (const key in source) {
    if (source[key] &amp;&amp; typeof source[key] === "object") {
      if (!target[key]) target[key] = {};
      merge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
}
</code></pre>
<p>Looks harmless. Now feed it a payload an attacker controls:</p>
<pre><code class="language-ts">const evil = JSON.parse('{"__proto__": {"isAdmin": true}}');
const account = {};
merge(account, evil);

console.log(account.isAdmin);   // undefined, account itself is fine
console.log(({}).isAdmin);      // true  &lt;-- every object is now "admin"
</code></pre>
<p>That second line is the horror. You never touched <code>({})</code>. You polluted the shared prototype, so a brand new empty object now reports <code>isAdmin: true</code>. If somewhere later your code does <code>if (user.isAdmin)</code> on an object that didn't explicitly set that field, congratulations! Everyone is an admin. The <code>__proto__</code> key tricked the merge into walking up into the prototype that all objects share.</p>
<p>The defense is to refuse the dangerous keys before they ever get into your data. The cleanest way at parse time is the reviver, a function <code>JSON.parse</code> calls for every key as it builds the result. Return <code>undefined</code> for a key and it gets dropped.</p>
<pre><code class="language-ts">const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);

function reviver(key, value) {
  if (FORBIDDEN_KEYS.has(key)) return undefined;
  return value;
}
</code></pre>
<p>That is it. Three key names, blocked at the door, and the merge above becomes harmless because the payload never carries <code>__proto__</code> past the parser.</p>
<p>For extra defense in depth, you can also build internal lookup objects with <code>Object.create(null)</code>, which creates an object with no prototype at all, or use a <code>Map</code> when the keys are user-controlled. And if you want a belt to go with the suspenders, <code>Object.freeze(Object.prototype)</code> early in your process start will make the whole class of attack fail loudly.</p>
<p>I wouldn't rely on freezing alone, because some libraries get unhappy about it, but blocking the keys costs you nothing and should be the default.</p>
<h2 id="heading-how-to-set-security-headers-on-every-response">How to Set Security Headers on Every Response</h2>
<p>Browsers will defend your users for you, but only if you tell them to. That instruction comes as a small set of response headers. For an API that returns JSON, the list is short and the defaults are strict, which is exactly how you want it.</p>
<pre><code class="language-ts">function secureHeaders(res) {
  // Do not let the browser guess content types. Stops a JSON response
  // from being treated as HTML or a script.
  res.setHeader("X-Content-Type-Options", "nosniff");

  // Clickjacking defense: do not allow this response inside a frame.
  res.setHeader("X-Frame-Options", "DENY");
  res.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'");

  // Do not leak the full URL (which may contain ids or tokens) on navigation.
  res.setHeader("Referrer-Policy", "no-referrer");

  // Only meaningful over HTTPS: force HTTPS for two years, including subdomains.
  res.setHeader("Strict-Transport-Security", "max-age=63072000; includeSubDomains");

  // Stop advertising what you are running. Free reconnaissance for nobody.
  res.removeHeader("X-Powered-By");
}
</code></pre>
<p>A quick tour, because cargo-culting headers is how you end up with a Content Security Policy that does nothing.</p>
<p><code>X-Content-Type-Options: nosniff</code> stops the browser from second-guessing your <code>Content-Type</code>, which closes a sneaky path where a response gets reinterpreted as something executable. <code>X-Frame-Options</code> and the <code>frame-ancestors</code> directive both refuse to let your responses be embedded in a frame, which is the heart of clickjacking. For a pure JSON API, <code>default-src 'none'</code> is a strong and appropriate CSP, because an API has no business loading scripts, styles, or images.</p>
<p><code>Referrer-Policy: no-referrer</code> keeps your URLs, which sometimes carry ids you would rather not gossip about, from being sent to other sites. <code>Strict-Transport-Security</code> only matters once you're on HTTPS, but once you are, it stops downgrade attacks by telling the browser to refuse plain HTTP.</p>
<p>And removing <code>X-Powered-By</code> is a tiny thing that just denies an attacker a free hint about what to throw at you.</p>
<p>The reason to wrap this in a function and call it on every response is that "every response" is the part humans forget. Make it one call you do at the top of the request, before you can get distracted.</p>
<h2 id="heading-how-to-compare-secrets-in-constant-time">How to Compare Secrets in Constant Time</h2>
<p>Here's a bug that looks completely fine and is completely broken:</p>
<pre><code class="language-ts">if (providedApiKey === expectedApiKey) {
  // grant access
}
</code></pre>
<p>The problem is that <code>===</code> on strings is allowed to be fast. It compares character by character and returns <code>false</code> the instant it finds a mismatch. That means a wrong guess that gets the first character right takes very slightly longer to reject than one that gets it wrong immediately.</p>
<p>That timing difference is tiny, but it's measurable over enough requests, and it lets an attacker recover a secret one character at a time. This is a real attack, it has a name (a timing attack), and the fix is built into Node.</p>
<p>You want a comparison whose running time doesn't depend on where the first difference is. Node gives you <code>crypto.timingSafeEqual</code> for exactly this. It has one sharp edge: it throws if the two buffers have different lengths, and length itself is a leak.</p>
<p>The clean way to handle both problems at once is to hash both inputs to a fixed size first, then compare the hashes.</p>
<pre><code class="language-ts">import { timingSafeEqual, createHash } from "node:crypto";

function safeCompare(a, b) {
  // Hashing normalizes length (so timingSafeEqual is happy) and hides
  // the length of the real secret from a timing observer.
  const ha = createHash("sha256").update(String(a)).digest();
  const hb = createHash("sha256").update(String(b)).digest();
  return timingSafeEqual(ha, hb);
}
</code></pre>
<p>Use this anywhere you compare a value a stranger supplied against a secret you hold: API keys, webhook signatures, password reset tokens, session identifiers. The rule of thumb is simple: if being wrong about the comparison would let someone in, don't use <code>===</code>.</p>
<p>One important caveat so you don't misuse this. For user passwords, don't store them and compare with this function. Passwords get hashed with a slow, purpose-built algorithm so that even if your database leaks, the hashes are expensive to crack.</p>
<p>Node ships <code>scrypt</code> for this in <code>node:crypto</code>, and <code>bcrypt</code> and <code>argon2</code> are popular libraries. The <code>safeCompare</code> above is for comparing high-entropy secrets like tokens and keys, not for human-chosen passwords.</p>
<h2 id="heading-how-to-validate-input-as-a-gate-not-a-suggestion">How to Validate Input as a Gate, Not a Suggestion</h2>
<p>Everything so far has been about surviving hostile input at the transport level. Validation is about refusing input that doesn't match the shape your code expects, before that input reaches your business logic.</p>
<p>A surprising amount of "weird production behavior" is just a handler that assumed a field was a string and got an array, or assumed a number and got the string "NaN".</p>
<p>You can hand-roll validation for a small API, and it's healthy to see what that looks like before you reach for a library:</p>
<pre><code class="language-ts">function expect(condition, message) {
  if (!condition) throw httpError(400, message);
}

function parseCreateUser(data) {
  expect(typeof data.email === "string" &amp;&amp; data.email.includes("@"), "email is required");
  expect(typeof data.password === "string", "password is required");
  expect(data.password.length &gt;= 12, "password must be at least 12 characters");
  // Return only the fields you actually want. Ignore everything else.
  return { email: data.email, password: data.password };
}
</code></pre>
<p>Notice the last line. You build a fresh object with only the fields you asked for, rather than passing <code>data</code> straight through. This quietly closes a mass-assignment hole, where a client sends <code>{"email": "...", "password": "...", "role": "admin"}</code> and a careless handler writes the whole object into the database, role included. If you only copy the fields you meant to accept, the extra ones never matter.</p>
<p>For anything beyond a few routes, a schema library pays for itself fast. Zod and Valibot are the popular choices, and both let you describe the shape once and get validation plus inferred types out of it.</p>
<pre><code class="language-ts">import { z } from "zod";

const CreateUser = z
  .object({
    email: z.string().email(),
    password: z.string().min(12),
  })
  .strict(); // reject unknown keys instead of ignoring them

const result = CreateUser.safeParse(data);
if (!result.success) throw httpError(400, "Validation failed");
const user = result.data;
</code></pre>
<p>That <code>.strict()</code> call is doing the same mass-assignment defense as our hand-rolled version, but declaratively. Whether you hand-roll it or use a library, the principle is the same: input is guilty until proven to match a shape you defined on purpose.</p>
<h2 id="heading-how-to-fail-without-leaking-and-log-so-you-can-see-it">How to Fail Without Leaking and Log So You Can See It</h2>
<p>Errors are going to happen. The real question is what your server says when they do, and whether you can reconstruct what went wrong afterward.</p>
<p>There are two common ways to get this wrong, and they are opposites. Either you hand attackers a map of your internals, or you blind yourself during an incident.</p>
<p>The leaking version looks like this, and yes, I've shipped it:</p>
<pre><code class="language-ts">catch (err) {
  res.writeHead(500);
  res.end(err.stack); // please do not
}
</code></pre>
<p>That stack trace can include file paths, library versions, query fragments, and sometimes secrets that got interpolated into an error message. It's a free briefing for whoever is poking at you.</p>
<p>The rule is blunt: a 500 should tell the client nothing useful, and tell you everything, through your logs.</p>
<p>The opposite failure is hiding the error so thoroughly that when it happens in production at 2am you have nothing to go on.</p>
<p>The fix for both problems is the same small idea: a request id. It's a short unique value you attach to each request, return to the client in a header, and include in every log line for that request. When a user reports "I got an error and it said request abc123," you can find exactly that request in your logs in seconds.</p>
<pre><code class="language-ts">import { randomUUID } from "node:crypto";

function withRequestId(req, res) {
  const requestId = req.headers["x-request-id"] ?? randomUUID();
  res.setHeader("X-Request-Id", requestId);
  return requestId;
}

function log(level, requestId, message, extra = {}) {
  // Structured logs: one JSON object per line, easy to search and ship.
  console.log(
    JSON.stringify({ level, requestId, message, ...extra, at: new Date().toISOString() }),
  );
}

function sendError(res, err, requestId) {
  const status = err.statusCode ?? 500;
  const message = status === 500 ? "Internal Server Error" : err.message;
  if (status === 500) {
    log("error", requestId, "unhandled error", { stack: err.stack });
  }
  if (!res.headersSent) {
    res.writeHead(status, { "Content-Type": "application/json" });
  }
  res.end(JSON.stringify({ error: message, requestId }));
}
</code></pre>
<p>The client gets the <code>requestId</code> but never the details. They can quote it to support, but they can't read your stack trace. Accept an incoming <code>X-Request-Id</code> when a trusted upstream set one, so a single request keeps the same id as it moves across your services, but generate your own whenever it is missing. Structured logs, one JSON object per line, are worth the slight ugliness, because they're trivial to filter and feed into a log aggregator, which a pile of freeform <code>console.log</code> calls is not.</p>
<p>One note on environments. It's fine, even helpful, to return richer error detail when you run locally. Just gate it on an explicit environment check and make production the strict default, so the worst outcome of a misconfiguration is too little information leaked, never too much.</p>
<h2 id="heading-how-to-put-it-all-together">How to Put It All Together</h2>
<p>None of these guardrails is impressive on its own. The power is in having all of them on, by default, on every route, so that the safe path is the path of least resistance.</p>
<p>Here is the naïve server from the start, rebuilt with everything we covered. It's still tiny. It's just no longer naïve.</p>
<pre><code class="language-ts">import http from "node:http";
import { timingSafeEqual, createHash } from "node:crypto";

const MAX_BODY_BYTES = 100 * 1024;
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);

function httpError(statusCode, message) {
  return Object.assign(new Error(message), { statusCode });
}

function reviver(key, value) {
  return FORBIDDEN_KEYS.has(key) ? undefined : value;
}

function readBody(req, limit = MAX_BODY_BYTES) {
  return new Promise((resolve, reject) =&gt; {
    const declared = Number(req.headers["content-length"]);
    if (Number.isFinite(declared) &amp;&amp; declared &gt; limit) {
      return reject(httpError(413, "Payload too large"));
    }
    let size = 0;
    const chunks = [];
    req.on("data", (chunk) =&gt; {
      size += chunk.length;
      if (size &gt; limit) {
        reject(httpError(413, "Payload too large"));
        req.destroy();
        return;
      }
      chunks.push(chunk);
    });
    req.on("end", () =&gt; resolve(Buffer.concat(chunks)));
    req.on("error", reject);
  });
}

function parseJson(buffer) {
  if (buffer.length === 0) return {};
  try {
    return JSON.parse(buffer.toString("utf8"), reviver);
  } catch {
    throw httpError(400, "Invalid JSON body");
  }
}

function secureHeaders(res) {
  res.setHeader("X-Content-Type-Options", "nosniff");
  res.setHeader("X-Frame-Options", "DENY");
  res.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'");
  res.setHeader("Referrer-Policy", "no-referrer");
  res.setHeader("Strict-Transport-Security", "max-age=63072000; includeSubDomains");
  res.removeHeader("X-Powered-By");
}

function safeCompare(a, b) {
  const ha = createHash("sha256").update(String(a)).digest();
  const hb = createHash("sha256").update(String(b)).digest();
  return timingSafeEqual(ha, hb);
}

function sendJson(res, status, payload) {
  if (!res.headersSent) {
    res.writeHead(status, { "Content-Type": "application/json" });
  }
  res.end(JSON.stringify(payload));
}

function sendError(res, err) {
  const status = err.statusCode ?? 500;
  // Never leak internal error details on a 500. Log them, do not ship them.
  const message = status === 500 ? "Internal Server Error" : err.message;
  if (status === 500) console.error(err);
  sendJson(res, status, { error: message });
}

const API_KEY = process.env.API_KEY ?? "dev-only-key";

async function handler(req, res) {
  secureHeaders(res);

  // A single protected route, as an example.
  if (req.method === "POST" &amp;&amp; req.url === "/users") {
    const provided = req.headers["x-api-key"] ?? "";
    if (!safeCompare(provided, API_KEY)) {
      throw httpError(401, "Unauthorized");
    }

    const data = parseJson(await readBody(req));

    if (typeof data.email !== "string" || !data.email.includes("@")) {
      throw httpError(400, "email is required");
    }
    if (typeof data.password !== "string" || data.password.length &lt; 12) {
      throw httpError(400, "password must be at least 12 characters");
    }

    // Only the fields we asked for. No mass assignment.
    const user = { email: data.email };
    return sendJson(res, 201, { created: user });
  }

  throw httpError(404, "Not found");
}

const server = http.createServer((req, res) =&gt; {
  handler(req, res).catch((err) =&gt; sendError(res, err));
});

server.requestTimeout = 30_000;
server.headersTimeout = 10_000;
server.setTimeout(60_000);

server.listen(3000, () =&gt; console.log("listening on http://localhost:3000"));
</code></pre>
<p>Read that top to bottom and notice how the security isn't a separate "security middleware" bolted on at the end. It's woven into the normal flow.</p>
<p>The body has a limit. The JSON is parsed safely. The headers go out every time. The API key check is timing-safe. The validation runs before any logic. The error handler refuses to leak internals. And the whole thing is still small enough to hold in your head, which matters, because security you can't understand is security you'll eventually disable by accident.</p>
<p>Try breaking it. Send a huge body and watch the 413. Send <code>{"__proto__": {"isAdmin": true}}</code> and confirm <code>({}).isAdmin</code> is still <code>undefined</code> afterward. Send a wrong API key and notice you can't tell from the response time how close you were. That last one is invisible by design, which is the whole point.</p>
<h2 id="heading-how-to-handle-cors-correctly">How to Handle CORS Correctly</h2>
<p>CORS, which stands for Cross-Origin Resource Sharing, is one of the most misunderstood security features in web development, and the misunderstanding is the dangerous kind.</p>
<p>Here's the part people get wrong: CORS doesn't protect your server. It's not a firewall. It's a browser feature that decides whether JavaScript running on one website is allowed to read the response from your API on another. Your server stays perfectly reachable from curl, from Postman, and from any other server, CORS headers or not.</p>
<p>What that means in practice is that the most common "fix" people apply is also the most common mistake:</p>
<pre><code class="language-ts">res.setHeader("Access-Control-Allow-Origin", "*"); // understand this before you ship it
</code></pre>
<p>A wildcard says "any website's JavaScript may read my responses." For a genuinely public, read-only API with no credentials, that can be perfectly fine. For anything that uses cookies or returns data tied to a logged-in user, it's a mistake, and browsers will refuse to combine <code>*</code> with credentials anyway.</p>
<p>The correct approach is to allow only the origins you actually trust:</p>
<pre><code class="language-ts">const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);

function applyCors(req, res) {
  const origin = req.headers.origin;
  if (origin &amp;&amp; ALLOWED_ORIGINS.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin"); // so a cache does not mix origins up
    res.setHeader("Access-Control-Allow-Credentials", "true");
  }
}
</code></pre>
<p>Keep this mental model: CORS loosens the browser's default protection in a controlled way. Setting it to <code>*</code> doesn't make your API more exposed to attacks from other servers, because servers were never restricted in the first place. It makes your data readable by any web page a victim happens to visit, which is a privacy and data-exposure decision, not a "make the console error go away" decision. Decide it on purpose, origin by origin.</p>
<h2 id="heading-what-this-tutorial-doesnt-cover">What This Tutorial Doesn't Cover</h2>
<p>Honesty time, because a tutorial that pretends to be totally complete is doing you a disservice. The guardrails above are the baseline, not the finish line.</p>
<p>Here's what's deliberately out of scope and where to look next:</p>
<ul>
<li><p><strong>Authentication and authorization:</strong> You checked one API key. Real apps need sessions or tokens, and a real story for who is allowed to do what. That is a whole topic on its own.</p>
</li>
<li><p><strong>Rate limiting:</strong> A single client shouldn't be able to hammer your login route ten thousand times a minute. In-memory counters work for one instance. Behind a load balancer you need a shared store like Redis.</p>
</li>
<li><p><strong>Outbound request safety (SSRF):</strong> The moment your server makes requests to URLs a user supplied, you have a new attack surface: someone can point you at internal addresses or the cloud metadata endpoint. That deserves its own article.</p>
</li>
<li><p><strong>TLS:</strong> Everything HSTS-related assumes you actually terminate HTTPS somewhere, whether that's the runtime, a reverse proxy, or your platform.</p>
</li>
<li><p><strong>Logging and monitoring:</strong> You can't respond to what you can't see. Structured logs with request ids are the unglamorous foundation of every incident response that went well.</p>
</li>
</ul>
<p>Each of these is a future tutorial, and each one follows the same philosophy as this one: make the safe choice the default, and make the unsafe choice something you have to go out of your way to do.</p>
<h2 id="heading-why-defaults-beat-checklists">Why Defaults Beat Checklists</h2>
<p>You might be wondering why I keep saying "by default" instead of just handing you a checklist and wishing you luck. The reason is that I've watched a lot of checklists lose to a deadline.</p>
<p>A checklist is a list of things a human has to remember to do, correctly, every single time, forever. That includes the junior dev who joined last week, and the senior dev who's exhausted and shipping a hotfix at midnight.</p>
<p>Security that depends on perfect human memory is security that quietly degrades the moment the team gets busy, which is precisely the moment an attacker is hoping for.</p>
<p>A default is a different kind of thing. A default is what happens when nobody does anything at all. If the safe behavior is the default, then forgetting produces a safe app. If the unsafe behavior is the default, then forgetting produces a vulnerability, and people forget constantly, because they're human and they have forty other things on their plate.</p>
<p>This is exactly why the helpers in this article are wrappers you call once at the top of a request, instead of steps you sprinkle through your handlers and hope you got them all. It's the same reason frameworks that take security seriously turn protections on and make you opt out, rather than leaving them off and making you opt in.</p>
<p>The wording sounds like a small difference. Measured across a real team over a real year, the difference in outcomes is enormous. Design it so the lazy path and the safe path are the same path, and you'll be surprised how secure "lazy" can be.</p>
<h2 id="heading-an-honest-note-on-frameworks">An Honest Note on Frameworks</h2>
<p>If wiring all of this by hand every time sounds tedious, that's exactly the right instinct, and it's why some frameworks ship these protections on by default so you don't have to remember them.</p>
<p>Full disclosure: I maintain one of them, an open-source project called DaloyJS. I'm not here to sell it, and everything in this article is plain Node that works the same no matter what you build on.</p>
<p>I mention it only because the lesson that produced it is the lesson of this whole piece: the defaults are the product. A framework that makes you opt into safety will, statistically, be run by someone who forgot to.</p>
<p>Whether you use a framework or roll your own, copy the helpers above into your project today. They're dependency-free and they will quietly prevent a category of bad days.</p>
<h2 id="heading-the-takeaway-checklist">The Takeaway Checklist</h2>
<p>If you remember nothing else, remember this list and put it somewhere your team will see it:</p>
<ul>
<li><p><strong>Limit the body:</strong> Count bytes as they stream, reject past your cap, never trust <code>Content-Length</code> alone.</p>
</li>
<li><p><strong>Time out everything:</strong> Tighten Node's request and header timeouts, and give every outbound call a deadline with <code>AbortController</code>.</p>
</li>
<li><p><strong>Parse JSON defensively:</strong> Catch parse errors into a clean 400, and strip <code>__proto__</code>, <code>constructor</code>, and <code>prototype</code> with a reviver.</p>
</li>
<li><p><strong>Set security headers on every response:</strong> Wrap them in one function so "every response" actually means every response.</p>
</li>
<li><p><strong>Compare secrets in constant time:</strong> Use <code>crypto.timingSafeEqual</code> on hashed inputs, never <code>===</code>, and use a real password hash for passwords.</p>
</li>
<li><p><strong>Validate input as a gate:</strong> Define the shape on purpose, reject what doesn't match, and copy only the fields you asked for.</p>
</li>
</ul>
<p>None of this is clever. That's the best thing about it. The boring stuff is what gets you, so make the boring stuff automatic, and go spend your cleverness on the parts of your product that actually need it.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Choose the Best Stock Market API for FinTech Projects and AI Agents  ]]>
                </title>
                <description>
                    <![CDATA[ Choosing a stock API looks simple until the project becomes real. At first, you only need a few prices. You send a request, get JSON back, load it into pandas, and move on. But the moment that API sta ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-choose-the-best-stock-market-api-for-fintech-projects-and-ai-agents/</link>
                <guid isPermaLink="false">6a24b9c567572e709df513c8</guid>
                
                    <category>
                        <![CDATA[ fintech ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #Stock market ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai agents ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Sun, 07 Jun 2026 00:22:29 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e1f20d3c-eaf8-49e9-be53-4cc99eb971ec.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Choosing a stock API looks simple until the project becomes real.</p>
<p>At first, you only need a few prices. You send a request, get JSON back, load it into pandas, and move on. But the moment that API starts powering a backtester, dashboard, screener, valuation tool, or AI assistant, the decision becomes much more serious.</p>
<p>A backtester needs adjusted historical prices, splits, dividends, and stable time series. A dashboard needs fresh quotes, clean fields, and reliable responses. A stock screener needs fundamentals, ratios, and company metadata. An AI agent needs structured data that it can retrieve and use without guessing.</p>
<p>That's why I wouldn't start by comparing endpoint counts or pricing pages. Those matter, but they're not the first question.</p>
<p>The first question is: <strong>what are you building?</strong></p>
<p>In this article, we’ll walk through how to choose a stock market API based on the workflow it needs to support. Then we’ll build a practical stock research workflow in Python using Alpha Vantage to see how prices, fundamentals, technical indicators, and AI-ready access can fit together in one project.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-stock-api-choice-depends-on-the-workflow">Why Stock API Choice Depends On The Workflow</a></p>
<ul>
<li><p><a href="#heading-1-if-you-are-building-a-backtester">1. If You Are Building A Backtester</a></p>
</li>
<li><p><a href="#heading-2-if-you-are-building-a-dashboard">2. If You Are Building A Dashboard</a></p>
</li>
<li><p><a href="#heading-3-if-you-are-building-a-stock-screener">3. If You Are Building A Stock Screener</a></p>
</li>
<li><p><a href="#heading-4-if-you-are-building-a-valuation-or-research-tool">4. If You Are Building A Valuation Or Research Tool</a></p>
</li>
<li><p><a href="#heading-5-if-you-are-building-an-ai-assistant-or-agent">5. If You Are Building An AI Assistant Or Agent</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-a-modern-stock-market-data-workflow-actually-requires">What A Modern Stock Market Data Workflow Actually Requires</a></p>
</li>
<li><p><a href="#heading-building-a-practical-stock-research-workflow-with-alpha-vantage">Building A Practical Stock Research Workflow With Alpha Vantage</a></p>
<ul>
<li><p><a href="#heading-step-1-fetch-adjusted-historical-prices">Step 1: Fetch Adjusted Historical Prices</a></p>
</li>
<li><p><a href="#heading-step-2-add-company-or-fundamental-data">Step 2: Add Company Or Fundamental Data</a></p>
</li>
<li><p><a href="#heading-step-3-add-technical-indicators">Step 3: Add Technical Indicators</a></p>
</li>
<li><p><a href="#heading-step-4-combine-everything-into-a-research-ready-table">Step 4: Combine Everything Into A Research-Ready Table</a></p>
</li>
<li><p><a href="#heading-step-5-connect-the-workflow-to-ai-agents-with-mcp">Step 5: Connect The Workflow To AI Agents With MCP</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-where-each-provider-fits-in-the-stock-api-workflow">Where Each Provider Fits In The Stock API Workflow</a></p>
</li>
<li><p><a href="#provider-breakdown-through-a-workflow-lens">Provider Breakdown Through A Workflow Lens</a></p>
<ul>
<li><p><a href="#heading-1-when-the-project-needs-several-data-layers-alpha-vantage">1. When The Project Needs Several Data Layers: Alpha Vantage</a></p>
</li>
<li><p><a href="#heading-2-when-the-workflow-is-institutional-bloomberg-api">2. When The Workflow Is Institutional: Bloomberg API</a></p>
</li>
<li><p><a href="#heading-3-when-the-product-needs-investor-relations-widgets-quotemedia">3. When The Product Needs Investor Relations Widgets: QuoteMedia</a></p>
</li>
<li><p><a href="#heading-4-when-the-workflow-is-global-historical-research-eodhd">4. When The Workflow Is Global Historical Research: EODHD</a></p>
</li>
<li><p><a href="#5-when-the-workflow-needs-us-fundamentals-intrinio">5. When The Workflow Needs US Fundamentals: Intrinio</a></p>
</li>
<li><p><a href="#heading-6-when-the-workflow-needs-enterprise-data-delivery-xignite">6. When The Workflow Needs Enterprise Data Delivery: Xignite</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-final-checklist-before-choosing-a-stock-api">Final Checklist Before Choosing A Stock API</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ul>
<h2 id="heading-why-stock-api-choice-depends-on-the-workflow"><strong>Why Stock API Choice Depends On The Workflow</strong></h2>
<p>A stock API should be judged by the workflow it supports, not by how long its feature list looks. The same provider can be a good fit for one project and a weak fit for another.</p>
<p>A clean historical dataset matters more for a backtester than a live quote endpoint. A dashboard has different problems. It needs fresh responses, predictable fields, and rate limits that don't collapse once users start refreshing the page.</p>
<p>Here is how I would think about it.</p>
<h3 id="heading-1-if-you-are-building-a-backtester">1. If You Are Building A Backtester</h3>
<h4 id="heading-start-with-historical-data-quality">Start with historical data quality.</h4>
<p>A backtest needs adjusted prices, splits, dividends, long history, and stable time series. If those pieces are wrong, the backtest can still run, but the results may be misleading.</p>
<p>For this workflow, real-time data is usually secondary. Clean historical data matters more than fast quotes.</p>
<h3 id="heading-2-if-you-are-building-a-dashboard">2. If You Are Building A Dashboard</h3>
<h4 id="heading-start-with-freshness-and-reliability">Start with freshness and reliability.</h4>
<p>A dashboard needs quote data that updates consistently, fields that don't change unexpectedly, and rate limits that can handle repeated requests. A failed request in a notebook is annoying. A failed request in a user-facing dashboard is a product problem.</p>
<p>You also need to check whether the data can be displayed to users. Licensing becomes part of the workflow once the dashboard is public.</p>
<h3 id="heading-3-if-you-are-building-a-stock-screener">3. If You Are Building A Stock Screener</h3>
<h4 id="heading-start-with-fundamentals-and-structured-fields">Start with fundamentals and structured fields.</h4>
<p>A screener needs more than prices. It may need ratios, company profiles, sector data, market cap, earnings, and symbol coverage across many companies.</p>
<p>The hard part is comparison. If fields are inconsistent across tickers, the screener becomes a cleanup project before it becomes a useful tool.</p>
<h3 id="heading-4-if-you-are-building-a-valuation-or-research-tool">4. If You Are Building A Valuation Or Research Tool</h3>
<h4 id="heading-start-with-financial-statements">Start with financial statements.</h4>
<p>A valuation workflow usually needs income statements, balance sheets, cash flow statements, earnings history, and historical fundamentals. Price data gives market context, but the business data does the heavier work.</p>
<p>This is where depth matters. The latest numbers are useful, but trends across multiple periods are often more important.</p>
<h3 id="heading-5-if-you-are-building-an-ai-assistant-or-agent">5. If You Are Building An AI Assistant Or Agent</h3>
<h4 id="heading-start-with-structure">Start with structure.</h4>
<p>An AI agent shouldn't guess financial data from memory. It needs predictable API responses, clear schemas, and tool access it can use reliably.</p>
<p>This is where MCP-style workflows matter. If an agent can call a tool, retrieve a quote, pull fundamentals, or fetch a time series cleanly, the API becomes part of the agent’s reasoning loop.</p>
<p>The practical point is simple: choose the API around the system you're building. Once the workflow is clear, the rest of the decision becomes much easier.</p>
<h2 id="heading-what-a-modern-stock-market-data-workflow-actually-requires"><strong>What A Modern Stock Market Data Workflow Actually Requires</strong></h2>
<p>A modern stock data workflow is rarely just one API call.</p>
<p>You might start with market data, but most useful projects eventually need more layers. A research dashboard may need fundamentals. A screener may need technical indicators. An AI assistant may need structured responses that it can retrieve through a tool.</p>
<p>A simple way to think about the workflow is:</p>
<p><code>Market Data -&gt; Fundamentals -&gt; Indicators -&gt; Structured Responses -&gt; Programmatic Workflow -&gt; AI/Agent Access</code></p>
<p>Each layer solves a different problem.</p>
<ul>
<li><p><strong>Market data</strong> gives you prices, volume, returns, and historical movement.</p>
</li>
<li><p><strong>Fundamentals</strong> add business context through revenue, margins, cash flow, earnings, and company details.</p>
</li>
<li><p><strong>Indicators</strong> help convert raw prices into features that can support screening, research, or signal testing.</p>
</li>
<li><p><strong>Structured responses</strong> make the data easier to parse, join, and reuse.</p>
</li>
<li><p><strong>Programmatic workflows</strong> turn the raw API response into tables, charts, models, dashboards, or research outputs.</p>
</li>
<li><p><strong>AI or agent access</strong> lets an assistant call tools, retrieve current data, and work with structured financial context instead of relying only on static knowledge.</p>
</li>
</ul>
<p>This is why stock API choice matters beyond the first request. The API is not only there to return data but to support the way the project grows after the prototype.</p>
<h2 id="heading-building-a-practical-stock-research-workflow-with-alpha-vantage"><strong>Building A Practical Stock Research Workflow With Alpha Vantage</strong></h2>
<p>Now let’s turn the framework into something practical.</p>
<p>For this section, we’ll use Alpha Vantage as the implementation API because it gives us the main layers we need for this workflow: adjusted historical prices, company data, technical indicators, and MCP-style access for AI agents.</p>
<p>The goal isn't to test every endpoint. The goal is to build a small research workflow that shows what a useful stock API should help us do.</p>
<p>We’ll build this in five steps:</p>
<ol>
<li><p>Fetch adjusted historical prices.</p>
</li>
<li><p>Add company or fundamental data.</p>
</li>
<li><p>Add a technical indicator.</p>
</li>
<li><p>Combine everything into a research-ready table.</p>
</li>
<li><p>Connect the workflow to an AI-agent setup using MCP.</p>
</li>
</ol>
<p>By the end, we should have a simple but practical stock research table that can support a screener, dashboard, research notebook, or AI assistant.</p>
<h3 id="heading-step-1-fetch-adjusted-historical-prices">Step 1: Fetch Adjusted Historical Prices</h3>
<p>Adjusted prices are the first thing I would check for any research or backtesting workflow. Raw prices can break around stock splits or dividends, while adjusted prices keep the series more useful for return calculations.</p>
<p>Let’s fetch daily adjusted price data for Apple.</p>
<pre><code class="language-python">import requests
import pandas as pd

api_key = 'YOUR ALPHA VANTAGE API KEY'

symbol = 'AAPL'

url = f'https://www.alphavantage.co/query?function=TIME_SERIES_DAILY_ADJUSTED&amp;symbol={symbol}&amp;outputsize=compact&amp;apikey={api_key}'

response = requests.get(url)
data = response.json()

prices = pd.DataFrame(data['Time Series (Daily)']).T

prices.index = pd.to_datetime(prices.index)
prices = prices.sort_index()

prices = prices.rename(columns={
    '1. open': 'open',
    '2. high': 'high',
    '3. low': 'low',
    '4. close': 'close',
    '5. adjusted close': 'adjusted_close',
    '6. volume': 'volume',
    '7. dividend amount': 'dividend',
    '8. split coefficient': 'split'
})

price_cols = ['open', 'high', 'low', 'close', 'adjusted_close', 'volume', 'dividend', 'split']
prices[price_cols] = prices[price_cols].astype(float)

prices.tail()
</code></pre>
<p>The output gives us a clean daily price table as you can see in the image below:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/903925ac-462b-4684-9b51-98b6f6173f74.png" alt="903925ac-462b-4684-9b51-98b6f6173f74" style="display: block;" width="1878" height="556" loading="lazy">

<p>For a chart, you may only need <code>close</code>. For research or backtesting, I would usually work with <code>adjusted_close</code> because it handles corporate actions more safely. Next, we can convert the time series into a few basic price features.</p>
<pre><code class="language-python">latest_price = prices['adjusted_close'].iloc[-1] 
return_30d = prices['adjusted_close'].pct_change(30).iloc[-1] 
volatility_30d = prices['adjusted_close'].pct_change().tail(30).std() 

price_features = {'symbol': symbol, 'latest_price': latest_price, 'return_30d': return_30d, 'volatility_30d': volatility_30d}
price_features
</code></pre>
<p>This returns:</p>
<pre><code class="language-plaintext">{'symbol': 'AAPL',
 'latest_price': 312.06,
 'return_30d': 0.18583097277442007,
 'volatility_30d': 0.012845143800989936}
</code></pre>
<p>This is already more useful than a raw API response. We now have a small set of price features that can feed a dashboard, screener, research table, or AI-assisted stock analysis workflow.</p>
<h3 id="heading-step-2-add-company-or-fundamental-data">Step 2: Add Company Or Fundamental Data</h3>
<p>Price data tells us how the stock moved, but it doesn't tell us much about the company behind the ticker. For a screener, valuation tool, or research workflow, we need some business context too.</p>
<p>Alpha Vantage’s OVERVIEW endpoint gives company-level fields like sector, industry, market cap, PE ratio, EPS, profit margin, and other summary metrics. Let’s pull those fields and keep only the ones we need for this workflow.</p>
<pre><code class="language-python">overview_url = f'https://www.alphavantage.co/query?function=OVERVIEW&amp;symbol={symbol}&amp;apikey={api_key}'

response = requests.get(overview_url)
overview = response.json()

fundamental_features = {
    'symbol': symbol,
    'name': overview.get('Name'),
    'sector': overview.get('Sector'),
    'industry': overview.get('Industry'),
    'market_cap': overview.get('MarketCapitalization'),
    'pe_ratio': overview.get('PERatio'),
    'eps': overview.get('EPS'),
    'profit_margin': overview.get('ProfitMargin'),
    'beta': overview.get('Beta')
}

fundamental_features
</code></pre>
<p>This returns:</p>
<pre><code class="language-plaintext">{'symbol': 'AAPL',
 'name': 'Apple Inc',
 'sector': 'TECHNOLOGY',
 'industry': 'CONSUMER ELECTRONICS',
 'market_cap': 4583336182000.0,
 'pe_ratio': 37.73,
 'eps': 8.27,
 'profit_margin': 0.272,
 'beta': 1.065}
</code></pre>
<p>Now we have two layers: price behavior from the time series data and business context from the company overview. The next step is to add a technical indicator so the table includes a market-derived signal as well.</p>
<h3 id="heading-step-3-add-technical-indicators">Step 3: Add Technical Indicators</h3>
<p>Fundamentals give us business context, but many research workflows also need market-derived signals. A simple example is the relative strength index, or RSI, which is often used to measure recent momentum.</p>
<p>Alpha Vantage has a RSI endpoint, so we can pull the indicator directly instead of calculating it from scratch.</p>
<pre><code class="language-python">rsi_url = f'https://www.alphavantage.co/query?function=RSI&amp;symbol={symbol}&amp;interval=daily&amp;time_period=14&amp;series_type=close&amp;apikey={api_key}'

response = requests.get(rsi_url)
rsi_data = response.json()

rsi = pd.DataFrame(rsi_data['Technical Analysis: RSI']).T

rsi.index = pd.to_datetime(rsi.index)
rsi = rsi.sort_index()
rsi['RSI'] = rsi['RSI'].astype(float)

latest_rsi = rsi['RSI'].iloc[-1]

indicator_features = {
    'symbol': symbol,
    'rsi_14': latest_rsi
}

indicator_features
</code></pre>
<p>This returns:</p>
<pre><code class="language-plaintext">{'symbol': 'AAPL', 'rsi_14': 79.0043}
</code></pre>
<p>Now the workflow has three layers:</p>
<ul>
<li><p>price behavior from adjusted historical data</p>
</li>
<li><p>business context from company fundamentals</p>
</li>
<li><p>momentum context from a technical indicator</p>
</li>
</ul>
<p>None of these is enough on its own. Together, they start to look like a usable research workflow instead of a raw API test.</p>
<h3 id="heading-step-4-combine-everything-into-a-research-ready-table">Step 4: Combine Everything Into A Research-Ready Table</h3>
<p>Now we can combine the price, fundamentals, and indicator layers into one table.</p>
<p>This is the part that matters for most real projects. A dashboard, screener, notebook, or AI assistant usually needs a clean object it can reuse, not three separate raw API responses.</p>
<pre><code class="language-python">research_row = {
    **price_features,
    **fundamental_features,
    **indicator_features
}

research_table = pd.DataFrame([research_row])

research_table
</code></pre>
<p>This gives us a single-row research table:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/5d659e28-19e3-4455-a1d8-e9bbd02e3ace.png" alt="research table" style="display: block;" width="1864" height="126" loading="lazy">

<p>This table is simple, but it already supports several use cases.</p>
<p>A screener can filter on <code>pe_ratio</code>, <code>profit_margin</code>, or <code>rsi_14</code>. A dashboard can show price, returns, sector, and market cap. A research notebook can add more tickers and compare them. An AI assistant can receive this as a compact context object instead of parsing multiple API responses on its own.</p>
<p>That's the real benefit of building the workflow this way. The API calls are only the beginning. The useful output is the structured table you create from them.</p>
<h3 id="heading-step-5-connect-the-workflow-to-ai-agents-with-mcp">Step 5: Connect The Workflow To AI Agents With MCP</h3>
<p>The table we created is useful because it has a predictable structure, which is exactly what AI workflows need.</p>
<p>If an agent needs stock context, it shouldn't guess from memory or parse several raw API responses every time. It should call a tool, retrieve the data, and receive something clean enough to use.</p>
<p>A simplified MCP workflow looks like this:</p>
<p><code>User question -&gt; AI agent -&gt; MCP tool call -&gt; Stock API data -&gt; Structured response -&gt; Final answer</code></p>
<p>For example, a user might ask:</p>
<p><em>Is Apple looking expensive compared with its recent momentum?</em></p>
<p>An agent could retrieve price data, fundamentals, and an indicator such as RSI before answering. The important part is not that the model already “knows” the answer. It's that the model can call the right tool and work with current data.</p>
<p>That is where our research table helps:</p>
<pre><code class="language-python">research_table.to_dict(orient='records')[0]
</code></pre>
<p>This returns a compact dictionary:</p>
<pre><code class="language-plaintext">{'symbol': 'AAPL',
 'latest_price': 312.06,
 'return_30d': 0.18583097277442007,
 'volatility_30d': 0.012845143800989936,
 'name': 'Apple Inc',
 'sector': 'TECHNOLOGY',
 'industry': 'CONSUMER ELECTRONICS',
 'market_cap': 4583336182000.0,
 'pe_ratio': 37.73,
 'eps': 8.27,
 'profit_margin': 0.272,
 'beta': 1.065,
 'rsi_14': 79.0043}
</code></pre>
<p>This doesn't replace proper analysis, and it shouldn't be treated as investment advice. But it gives an AI assistant a cleaner starting point than raw JSON, stale model knowledge, or a vague prompt with no data attached.</p>
<p>AI readiness isn't just about saying an API supports agents. The API has to return data that can be retrieved, structured, checked, and passed into a workflow without fragile glue code at every step.</p>
<h2 id="heading-where-each-provider-fits-in-the-stock-api-workflow"><strong>Where Each Provider Fits In The Stock API Workflow</strong></h2>
<p>The workflow we built above is one version of a modern stock data project: prices, fundamentals, indicators, programmatic analysis, and AI-agent access working together.</p>
<p>Other projects may need a narrower or more specialized provider. Here's a practical way to compare the fit:</p>
<table style="min-width:653px"><colgroup><col style="min-width:25px"><col style="width:84px"><col style="width:75px"><col style="width:87px"><col style="width:90px"><col style="width:88px"><col style="width:83px"><col style="width:121px"></colgroup><tbody><tr><td><p><strong>Provider</strong></p></td><td><p><strong>Market Data</strong></p></td><td><p><strong>Fundamentals</strong></p></td><td><p><strong>Technical Indicators</strong></p></td><td><p><strong>Developer Workflow</strong></p></td><td><p><strong>AI / Agent Readiness</strong></p></td><td><p><strong>Workflow Completeness</strong></p></td><td><p><strong>Best Fit</strong></p></td></tr><tr><td><p>Alpha Vantage</p></td><td><p>Strong</p></td><td><p>Strong</p></td><td><p>Strong</p></td><td><p>Strong</p></td><td><p>Strong</p></td><td><p>High</p></td><td><p>Broad technical projects, research tools, screeners, dashboards, and AI-agent workflows</p></td></tr><tr><td><p>Bloomberg API</p></td><td><p>Very strong</p></td><td><p>Strong</p></td><td><p>Moderate</p></td><td><p>Enterprise-focused</p></td><td><p>Enterprise-dependent</p></td><td><p>High</p></td><td><p>Institutions already using Bloomberg internally</p></td></tr><tr><td><p>QuoteMedia</p></td><td><p>Strong</p></td><td><p>Moderate</p></td><td><p>Limited / Moderate</p></td><td><p>Moderate</p></td><td><p>Limited</p></td><td><p>Medium</p></td><td><p>Investor relations websites and embedded market data widgets</p></td></tr><tr><td><p>EODHD</p></td><td><p>Strong</p></td><td><p>Good</p></td><td><p>Good</p></td><td><p>Good</p></td><td><p>Strong</p></td><td><p>High</p></td><td><p>Global EOD history, backtesting, and historical research</p></td></tr><tr><td><p>Intrinio</p></td><td><p>Good</p></td><td><p>Strong</p></td><td><p>Limited / Moderate</p></td><td><p>Good</p></td><td><p>Limited / Moderate</p></td><td><p>Medium / High</p></td><td><p>US fundamentals, valuation tools, and professional datasets</p></td></tr><tr><td><p>Xignite</p></td><td><p>Strong</p></td><td><p>Good</p></td><td><p>Limited / Moderate</p></td><td><p>Enterprise-focused</p></td><td><p>Limited / Moderate</p></td><td><p>Medium / High</p></td><td><p>Enterprise financial applications needing vendor support</p></td></tr></tbody></table>

<p>No provider fits every workflow equally well. The point of this table is to show where the fit is strongest.</p>
<p>Alpha Vantage works well when a project needs several layers together, especially market data, fundamentals, indicators, developer usability, and AI-agent access. EODHD is stronger when the workflow is centered on global historical research. Intrinio fits better when standardized US fundamentals are the main requirement. Bloomberg API and Xignite are more natural for institutional or enterprise environments, while QuoteMedia is more specialized around investor relations and embedded market data widgets.</p>
<p>This is the right way to think about stock APIs: not as one universal winner, but as different tools for different workflow shapes.</p>
<h2 id="heading-provider-breakdown-through-a-workflow-lens"><strong>Provider Breakdown Through A Workflow Lens</strong></h2>
<p>The table gives a quick comparison. This section explains what that means in practice.</p>
<p>Instead of asking which provider is “best” in general, it is better to ask: what kind of workflow is this provider naturally built for?</p>
<h3 id="heading-1-when-the-project-needs-several-data-layers-alpha-vantage">1. When The Project Needs Several Data Layers: Alpha Vantage</h3>
<p>Alpha Vantage fits well when the project needs more than one type of market data in the same workflow.</p>
<p>In the workflow we built earlier, we used:</p>
<ul>
<li><p>adjusted historical prices</p>
</li>
<li><p>company data</p>
</li>
<li><p>technical indicators</p>
</li>
<li><p>structured output for programmatic analysis</p>
</li>
<li><p>a format that can also support AI-agent workflows</p>
</li>
</ul>
<p>That makes Alpha Vantage a flexible fit for stock research notebooks, screeners, dashboards, backtesting workflows, and AI assistants that need market data through tools or MCP-style access.</p>
<p>The main caveat is specialization. If your project needs direct exchange infrastructure, co-location, or a highly specialized institutional setup, you may need a more specialized provider. But for most research, fintech apps, and AI workflows, Alpha Vantage gives enough breadth without forcing you to combine several APIs too early.</p>
<h3 id="heading-2-when-the-workflow-is-institutional-bloomberg-api">2. When The Workflow Is Institutional: Bloomberg API</h3>
<p>Bloomberg API makes sense when the organization already uses Bloomberg internally.</p>
<p>It's best suited for firms that want to connect Bloomberg data with internal tools, reports, models, and risk systems.</p>
<p>This isn't usually the right fit for solo developers or small teams. The cost, licensing, and ecosystem dependency make it more suitable for institutions.</p>
<h3 id="heading-3-when-the-product-needs-investor-relations-widgets-quotemedia">3. When The Product Needs Investor Relations Widgets: QuoteMedia</h3>
<p>QuoteMedia fits products where the main need is public-facing market data display.</p>
<p>That can include:</p>
<ul>
<li><p>investor relations pages</p>
</li>
<li><p>quote widgets</p>
</li>
<li><p>embedded charts</p>
</li>
<li><p>company stock pages</p>
</li>
<li><p>market data modules for public websites</p>
</li>
</ul>
<p>This is different from building a programmatic research workflow. QuoteMedia makes more sense when presentation and embedded financial data are the core product requirement.</p>
<h3 id="heading-4-when-the-workflow-is-global-historical-research-eodhd">4. When The Workflow Is Global Historical Research: EODHD</h3>
<p>EODHD fits well when the project needs broad historical data across global markets.</p>
<p>It's useful for long-horizon backtesting, global screeners, and research workflows that depend on end-of-day data from many exchanges.</p>
<p>The tradeoff is cleanup. Global data often brings differences in symbols, exchange calendars, currencies, and local market conventions. That's manageable, but it should be expected.</p>
<h3 id="heading-5-when-the-workflow-needs-us-fundamentals-intrinio">5. When The Workflow Needs US Fundamentals: Intrinio</h3>
<p>Intrinio fits well when standardized US fundamentals are the center of the product.</p>
<p>It's useful for:</p>
<ul>
<li><p>valuation tools</p>
</li>
<li><p>earnings dashboards</p>
</li>
<li><p>fundamentals-based screeners</p>
</li>
<li><p>professional US equity research workflows</p>
</li>
</ul>
<p>The main thing to check is dataset fit. Before building around Intrinio, I would look closely at the specific datasets, access terms, and coverage levels the product needs.</p>
<h3 id="heading-6-when-the-workflow-needs-enterprise-data-delivery-xignite">6. When The Workflow Needs Enterprise Data Delivery: Xignite</h3>
<p>Xignite fits larger financial applications that need formal vendor support.</p>
<p>This can include banks, brokerages, wealth platforms, and enterprise fintech products where support, contracts, reliability, and data relationships matter as much as the endpoint itself.</p>
<p>For smaller developer projects, it may feel heavier than necessary. For enterprise products, that structure can be exactly the point.</p>
<h2 id="heading-final-checklist-before-choosing-a-stock-api"><strong>Final Checklist Before Choosing A Stock API</strong></h2>
<p>Before choosing a provider, I would run through this checklist.</p>
<table style="min-width:428px"><colgroup><col style="min-width:25px"><col style="width:403px"></colgroup><tbody><tr><td><p><strong>Question</strong></p></td><td><p><strong>Why It Matters</strong></p></td></tr><tr><td><p>What am I building?</p></td><td><p>A backtester, dashboard, screener, valuation tool, and AI assistant all need different things.</p></td></tr><tr><td><p>Do I need real-time, delayed, or historical data?</p></td><td><p>Real-time access matters only if the workflow actually needs it.</p></td></tr><tr><td><p>Do I need adjusted prices?</p></td><td><p>For backtesting and research, adjusted prices are usually non-negotiable.</p></td></tr><tr><td><p>Do I need fundamentals?</p></td><td><p>Screeners, valuation tools, and research dashboards usually need company data, not just prices.</p></td></tr><tr><td><p>Do I need technical indicators?</p></td><td><p>Signal testing, filters, and momentum-style analysis may need indicators directly from the API or calculated separately.</p></td></tr><tr><td><p>How many symbols will I query?</p></td><td><p>One ticker in a notebook is easy. Hundreds of tickers can expose rate-limit and performance issues quickly.</p></td></tr><tr><td><p>Will users see the data?</p></td><td><p>If yes, licensing, display rights, storage rules, and redistribution terms matter before the product goes live.</p></td></tr><tr><td><p>Is the response easy to parse in Python or other programming languages?</p></td><td><p>Clean JSON can save a lot of cleanup work once the project grows.</p></td></tr><tr><td><p>Can it support AI or agent workflows?</p></td><td><p>AI assistants need structured responses, tool compatibility, or MCP-style access.</p></td></tr><tr><td><p>Will this API still work after the prototype stage?</p></td><td><p>A provider can be easy to try and still be hard to build around.</p></td></tr></tbody></table>

<h2 id="heading-final-thoughts"><strong>Final Thoughts</strong></h2>
<p>A good stock API should reduce project risk, not just return data.</p>
<p>If you're building a small chart, almost any clean price endpoint can work. But once the same API starts supporting a backtester, screener, dashboard, valuation tool, or AI assistant, the decision becomes more important. The provider affects your data quality, parsing logic, refresh jobs, licensing choices, and future product direction.</p>
<p>This is why workflow fit matters more than endpoint count. For projects that need several layers together, such as real-time and historical market data, fundamentals, indicators, developer-friendly access, spreadsheet support, and MCP-style AI workflows, Alpha Vantage fits well. For narrower workflow needs, another provider may make more sense.</p>
<p>Choose the API as part of your project’s data infrastructure, not just as a list of endpoints.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Design APIs for AI Agents ]]>
                </title>
                <description>
                    <![CDATA[ APIs are designed for human developers. People read documentation, infer the intent behind an endpoint, and know how to handle edge cases when something unexpected happens. AI agents don't have that c ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-design-apis-for-ai-agents/</link>
                <guid isPermaLink="false">6a18bdb078258754833f8205</guid>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai-agent ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ David Aniebo ]]>
                </dc:creator>
                <pubDate>Thu, 28 May 2026 22:12:00 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/056b20d6-7409-4b6e-a29c-0b48061a7508.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>APIs are designed for human developers. People read documentation, infer the intent behind an endpoint, and know how to handle edge cases when something unexpected happens.</p>
<p>AI agents don't have that context and understanding.</p>
<p>AI agent understand APIs through schemas, examples, randomized data and live responses. When a behavior or method is ambiguous and inconsistent, the model doesn't pause to “think” – it fills in the blanks (randomizing).</p>
<p>In production, those guesses could become blocks, retry storms, duplicated side effects, or broken workflows.</p>
<p>This is why APIs that are perfectly fine for humans frequently fail under AI agent use. The problem is rarely “the agent isn’t smart enough.” More often, the API was never designed for an agent/machine consumer that must plan, call tools, and recover from failure without a human in the loop.</p>
<p>In this guide, you’ll learn how to design APIs that agents can use reliably. We’ll anchor the discussion in three practical ideas:</p>
<ol>
<li><p><strong>Deterministic behavior:</strong> same inputs and state should yield predictable outcomes and shapes.</p>
</li>
<li><p><strong>Strong schemas:</strong> contracts that are complete, descriptive, and testable.</p>
</li>
<li><p><strong>Guardrails at the API boundary:</strong> authorization, validation, and safe defaults that prevent unsafe autonomy.</p>
</li>
</ol>
<p>The aim of this article is not to build “AI-powered” APIs, but rather to build APIs that are <strong>clear, strict,</strong> and <strong>dependable,</strong> even when the caller is not an agent but a fellow developers leveraging various tools.</p>
<h2 id="heading-table-of-contents">Table Of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-why-good-enough-for-devs-is-not-good-enough-for-agents">Why “Good Enough for Devs” Is Not Good Enough for Agents</a></p>
</li>
<li><p><a href="#heading-principle-1-deterministic-behavior">Principle 1: Deterministic Behavior</a></p>
</li>
<li><p><a href="#heading-principle-2-strong-schemas">Principle 2: Strong Schemas</a></p>
</li>
<li><p><a href="#heading-principle-3-guardrails-at-the-api-boundary">Principle 3: Guardrails at the API Boundary</a></p>
</li>
<li><p><a href="#heading-patterns-that-bridge-apis-and-agent-runtimes">Patterns That Bridge APIs and Agent Runtimes</a></p>
</li>
<li><p><a href="#heading-a-practical-before-and-after-example">A Practical Before and After Example</a></p>
</li>
<li><p><a href="#heading-checklist-is-your-api-agent-ready">Checklist: Is Your API Agent-Ready?</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before reading this guide, it helps to have:</p>
<ul>
<li><p>A basic understanding of HTTP APIs and REST concepts</p>
</li>
<li><p>Familiarity with JSON and API request/response patterns</p>
</li>
<li><p>An understanding of common API concepts like authentication, pagination, and retries</p>
</li>
</ul>
<h2 id="heading-why-good-enough-for-devs-is-not-good-enough-for-agents">Why “Good Enough for Devs” Is Not Good Enough for Agents</h2>
<p>Human developers bring implied and contextual knowledge: they read through Slack threads, read blog posts, and recognize that “this 404 usually means you forgot the workspace ID.”</p>
<p>Agents mostly get whatever is in the spec, the examples, and the last response body.</p>
<p>That gap shows up in predictable ways:</p>
<ul>
<li><p><strong>Ambiguous semantics:</strong> wrong endpoint or wrong parameter combination.</p>
</li>
<li><p><strong>Undocumented branches:</strong> the model invents fields or misreads optional behavior.</p>
</li>
<li><p><strong>Inconsistent error bodies:</strong> retries that shouldn't happen, or no retry when one is safe.</p>
</li>
<li><p><strong>Non-idempotent “do things” endpoints:</strong> duplicate charges, duplicate tickets, duplicate emails.</p>
</li>
</ul>
<p>Industry commentary and practitioner guides converge on the same point: agents are becoming a major class of API consumer, and machine legibility matters as much as developer experience.</p>
<p>See for example discussions of OpenAPI as the source of truth for agents, emerging tool protocols, and traffic patterns that differ from human clients in the resources listed at the end of this article.</p>
<h2 id="heading-principle-1-deterministic-behavior">Principle 1: Deterministic Behavior</h2>
<p>Determinism for agents doesn't mean “always return the same JSON forever.” It means: <strong>given the same request and the same server-side state, your API behaves in a way the agent can model</strong> and when state changes, you make that explicit.</p>
<h3 id="heading-prefer-explicit-state-over-hidden-magic">Prefer Explicit State Over Hidden Magic</h3>
<p>Agents struggle with “sometimes the server does X depending on internal flags.” Where humans infer intent from product copy, agents infer from patterns. If those patterns drift, autonomy breaks.</p>
<p>Practical habits:</p>
<ul>
<li><p>Model lifecycle explicitly (<code>draft</code> → <code>submitted</code> → <code>approved</code>) instead of overloading a single <code>status</code> field with undocumented combinations.</p>
</li>
<li><p>Return what changed after mutations (updated resource, relevant IDs, next allowed actions).</p>
</li>
<li><p>Avoid silent coercion (auto-correcting bad enums, silently dropping unknown fields) unless you document and signal it.</p>
</li>
</ul>
<h3 id="heading-make-writes-safe-idempotency-and-intent-keys">Make Writes Safe: Idempotency and Intent Keys</h3>
<p>For any endpoint that bills, sends messages, provisions infrastructure, or otherwise <strong>does something irreversible</strong>, assume double-submission will happen.</p>
<ul>
<li><p>Support idempotency keys (header or body) for create-like operations.</p>
</li>
<li><p>Use clear HTTP semantics: <code>POST</code> creates, <code>PUT</code> replaces where appropriate, <code>PATCH</code> for partial updates and document what repeats mean.</p>
</li>
<li><p>Where duplicates are possible, offer a lookup-by-client-reference path so agents can reconcile.</p>
</li>
</ul>
<h3 id="heading-pagination-and-sorting-one-pattern-everywhere">Pagination and Sorting: One Pattern, Everywhere</h3>
<p>Agents loop. If every resource paginates differently, the model will mix strategies.</p>
<p>To combat this, pick one pagination style (cursor vs offset) per API surface and stick to it.</p>
<p>Also, always return stable sort order or require <code>sort</code> explicitly. You should also include <code>next</code> links or cursors in a consistent envelope.</p>
<h3 id="heading-timeouts-partial-success-and-async-work">Timeouts, Partial Success, and Async Work</h3>
<p>Agents hate “maybe it worked.” Long-running work should be <strong>explicitly async</strong>:</p>
<ul>
<li><p><code>202 Accepted</code> + job ID + polling or webhooks.</p>
</li>
<li><p>Clear terminal states: <code>succeeded</code>, <code>failed</code>, <code>canceled</code>, with structured error details on failure.</p>
</li>
</ul>
<h2 id="heading-principle-2-strong-schemas">Principle 2: Strong Schemas</h2>
<p>If determinism is about behavior, schemas are about communication. For agents, your OpenAPI (or equivalent) isn't paperwork, it's part of the runtime interface.</p>
<h3 id="heading-treat-openapi-as-a-contract-not-a-souvenir">Treat OpenAPI as a Contract, Not a Souvenir</h3>
<p>A specification that lags production is worse than no spec: it trains the agent to be confidently wrong. Teams increasingly treat OpenAPI as the authoritative contract and validate requests/responses against it in CI and at the edge.</p>
<p>Here's the minimum bar for agent-friendly OpenAPI:</p>
<ul>
<li><p>Every operation has a <code>summary</code> and a <code>description</code> that explain <em>when</em> to use it, not only <em>what</em> it returns.</p>
</li>
<li><p>Every request body property has <code>description</code> and realistic <code>example</code> values.</p>
</li>
<li><p>All responses are documented including 4xx/5xx with stable JSON shapes.</p>
</li>
</ul>
<h3 id="heading-describe-intent-in-natural-language-precisely">Describe Intent in Natural Language, Precisely</h3>
<p>Agents aren't offended by verbosity. They're confused by vague verbs.</p>
<p>Instead of:</p>
<blockquote>
<p>“Gets orders.”</p>
</blockquote>
<p>Prefer:</p>
<blockquote>
<p>“Lists orders for the authenticated merchant. Supports filtering by <code>status</code> and a time window on <code>created_at</code>. Returns at most <code>limit</code> items; use <code>cursor</code> for the next page.”</p>
</blockquote>
<p>This aligns with what multiple guides call <strong>context-aware</strong> or <strong>self-describing</strong> APIs: the schema carries semantic intent, not just types.</p>
<h3 id="heading-examples-are-part-of-the-contract">Examples Are Part of the Contract</h3>
<p>You should provide a happy path example per endpoint, at least one validation error example (400) with your standard error object, and examples for optional fields when they change behavior.</p>
<p>Examples reduce “shape hallucination” where the model guesses field names or nesting.</p>
<h3 id="heading-json-schema-strictness-helps-tool-calling-stacks">JSON Schema Strictness Helps Tool-Calling Stacks</h3>
<p>If your agent uses function calling / structured outputs, tighten schemas:</p>
<ul>
<li><p>Prefer <code>enum</code> for small closed sets.</p>
</li>
<li><p>Mark fields <code>required</code> honestly.</p>
</li>
<li><p>Use <code>format</code> (<code>uuid</code>, <code>date-time</code>) where real.</p>
</li>
<li><p>Avoid <code>additionalProperties: true</code> on security-sensitive payloads if you need strict validation.</p>
</li>
</ul>
<h3 id="heading-name-things-consistently">Name Things Consistently</h3>
<p><code>userId</code> in one endpoint and <code>user_id</code> in another is a human annoyance and an agent trap. Pick a convention and enforce it.</p>
<h2 id="heading-principle-3-guardrails-at-the-api-boundary">Principle 3: Guardrails at the API Boundary</h2>
<p>Autonomy amplifies mistakes. Guardrails turn “oops” into blocked requests instead of incidents.</p>
<h3 id="heading-authorization-should-be-narrow-and-explicit">Authorization Should Be Narrow and Explicit</h3>
<p>Agents should receive credentials scoped to <strong>least privilege</strong>. For example, use short-lived tokens, with refresh documented clearly. Use scopes that map to real actions (<code>orders:read</code> vs <code>orders:write</code>). And avoid flows that assume a human can solve (CAPTCHAs) or click (email links mid-run) or isolate those as human-in-the-loop tools.</p>
<h3 id="heading-validate-hard-fail-loud-and-structured">Validate Hard, Fail Loud and Structured</h3>
<p>Reject bad input at the edge with stable <code>error_code</code> values (machine-actionable), human-readable <code>message</code> (for logs and UI), optional <code>field</code> or JSON Pointer to the problem, and optional <code>doc_url</code> linking to documentation.</p>
<p>This matches guidance from several practitioner articles: opaque 500s and generic errors are where autonomous clients spiral.</p>
<p>RFC 7807 Problem Details (<code>application/problem+json</code>) is a good, widely understood pattern for HTTP APIs, a structured envelope agents can parse consistently.</p>
<h3 id="heading-separate-read-the-world-from-change-the-world">Separate “Read the World” from “Change the World”</h3>
<p>For high-impact actions (refunds, deletes, transfers), consider using a two-step pattern: first create an intent, then confirm execution.</p>
<p>Or you can dry-run query parameters / dedicated endpoints that validate without committing.</p>
<p>Also keep in mind that rate limits and quotas tuned for bursty agent behavior and autonomous loops can dwarf human traffic.</p>
<h3 id="heading-observability-is-a-product-feature">Observability is a Product Feature</h3>
<p>Log correlation IDs, surface them in responses where safe, and monitor for retry amplification. An agent that misreads a 409 as “retry forever” becomes a denial-of-wallet attack on your own systems.</p>
<h2 id="heading-patterns-that-bridge-apis-and-agent-runtimes">Patterns That Bridge APIs and Agent Runtimes</h2>
<h3 id="heading-workflow-documentation-sequences-not-just-endpoints">Workflow Documentation: Sequences, Not Just Endpoints</h3>
<p>Agents excel when they can follow a recipe. Document common sequences (“create customer → add payment method → charge”) and consider standards meant for multi-step API flows (such as Arazzo) when your product’s complexity justifies it.</p>
<h3 id="heading-hypermedia-and-next-steps">Hypermedia and “Next Steps”</h3>
<p>Including links to plausible next actions (for example, pagination <code>next</code>, or related resources) reduces improvisation. This is the same spirit as <a href="https://en.wikipedia.org/wiki/HATEOAS">HATEOAS</a>: the response whispers what you can do next, instead of forcing the model to guess URLs.</p>
<h3 id="heading-tool-oriented-surfaces-for-example-mcp">Tool-Oriented Surfaces (For Example, MCP)</h3>
<p>Protocols like the Model Context Protocol (MCP) are gaining traction as a way to expose curated capabilities (“tools”) with schemas agents can bind to directly.</p>
<p>A common pragmatic pattern is not to dump every micro-endpoint as a tool, but to expose coarse-grained tools aligned to user outcomes while keeping your underlying REST API strict and clean.</p>
<p>MCP isn't a substitute for good API design. It's a delivery and discovery layer. Slapping a thin wrapper on a messy API still leaves you with a messy system – it just fails faster in public.</p>
<h3 id="heading-metadata-for-discovery-llmstxt-and-friends">Metadata for Discovery (<code>llms.txt</code> and Friends)</h3>
<p>Some teams publish <code>/llms.txt</code> or similar lightweight discovery files for documentation sites. Treat these as optional signposts, not replacements for OpenAPI.</p>
<p>Ecosystem adoption is still evolving, but the underlying idea is sound: make the canonical machine-readable description easy to find.</p>
<h2 id="heading-a-practical-beforeafter">A Practical Before/After</h2>
<h3 id="heading-weak-pattern-agent-hostile">Weak Pattern (Agent-hostile)</h3>
<pre><code class="language-http">POST /do-stuff
</code></pre>
<p>Response <code>200 OK</code>:</p>
<pre><code class="language-json">{ "ok": true }
</code></pre>
<p>Problems: no idempotency, no structured error, no entity ID, no way to poll, the agent must guess whether “ok” means “created” or “ignored duplicate.”</p>
<h3 id="heading-stronger-pattern-agent-friendly">Stronger Pattern (Agent-friendly)</h3>
<pre><code class="language-http">POST /v1/invoices
Idempotency-Key: 7b3c-...
</code></pre>
<p>Response <code>201 Created</code>:</p>
<pre><code class="language-json">{
  "invoice": {
    "id": "inv_9Qz",
    "status": "draft",
    "total": { "amount": "120.00", "currency": "USD" }
  },
  "links": {
    "finalize": "/v1/invoices/inv_9Qz/finalize"
  }
}
</code></pre>
<p>Conflict response <code>409 Conflict</code> with Problem Details:</p>
<pre><code class="language-json">{
  "type": "https://api.example.com/problems/duplicate-idempotency-key",
  "title": "Duplicate idempotency key",
  "status": 409,
  "detail": "A different request body was sent with the same Idempotency-Key.",
  "error_code": "IDEMPOTENCY_KEY_REUSE_BODY_MISMATCH"
}
</code></pre>
<p>This tells the agent what happened and whether retrying is appropriate.</p>
<h2 id="heading-checklist-is-your-api-agent-ready">Checklist: Is Your API Agent-Ready?</h2>
<ul>
<li><p><strong>Contract</strong>: Published OpenAPI 3.x, validated against real traffic, with rich descriptions and examples.</p>
</li>
<li><p><strong>Determinism</strong>: Documented state machines, consistent pagination, explicit async for long jobs.</p>
</li>
<li><p><strong>Safe writes</strong>: Idempotency for side effects, reconciliation endpoints where needed.</p>
</li>
<li><p><strong>Errors</strong>: Stable codes, structured bodies, documented remediation paths.</p>
</li>
<li><p><strong>Security</strong>: Least-privilege tokens, no “mystery” side doors agents can accidentally hit.</p>
</li>
<li><p><strong>Operations</strong>: Rate limits, bulk endpoints where appropriate, correlation IDs, dashboards for anomalous agent traffic.</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Designing for AI agents is, in most respects, disciplined API design — pushed to the level where machines can rely on your contract without tribal knowledge.</p>
<p>If you remember only three things:</p>
<ol>
<li><p><strong>Be predictable:</strong> in shapes, states, and side effects.</p>
</li>
<li><p><strong>Be explicit:</strong> in schemas, examples, and errors.</p>
</li>
<li><p><strong>Be protective:</strong> validate early, scope narrowly, and make dangerous actions hard to trigger by accident.</p>
</li>
</ol>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Bypass Cloud SMTP Restrictions Using Brevo and HTTP APIs ]]>
                </title>
                <description>
                    <![CDATA[ Being able to communicate by sending emails through web applications is important these days. It helps businesses stay connected with their potential customers, securely verify user identities, and de ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-bypass-cloud-smtp-restrictions-using-brevo-and-http-apis/</link>
                <guid isPermaLink="false">69fe2a2ef239332df4f7f8b9</guid>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ brevo ]]>
                    </category>
                
                    <category>
                        <![CDATA[ google smtp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloud-smtp ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Okoro Emmanuel Nzube ]]>
                </dc:creator>
                <pubDate>Fri, 08 May 2026 18:23:42 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/64f68792-e18c-4b90-9c65-c6d9884ab191.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Being able to communicate by sending emails through web applications is important these days. It helps businesses stay connected with their potential customers, securely verify user identities, and deliver crucial notifications like password resets.</p>
<p>But sometimes, deploying your perfectly working email function to the cloud leads to unexpected and frustrating errors. You build your backend, test it locally, and it works flawlessly. Then you deploy to the cloud, and suddenly your app stops sending emails completely.</p>
<p>In this article, you’ll learn exactly why your email setup fails on cloud platforms like Render or Heroku, the underlying networking rules causing the issue, and how to elegantly bypass these restrictions using Brevo's HTTP API.</p>
<p>Let’s dive right in.</p>
<h2 id="heading-outline">Outline</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-tools-well-be-using">Tools We'll Be Using</a></p>
</li>
<li><p><a href="#heading-the-problem-nodemailer-and-smtp-blocking">The Problem: Nodemailer and SMTP Blocking</a></p>
</li>
<li><p><a href="#heading-the-modern-trap-domain-verification">The "Modern" Trap: Domain Verification</a></p>
</li>
<li><p><a href="#heading-the-ultimate-solution-brevo-and-http-apis">The Ultimate Solution: Brevo and HTTP APIs</a></p>
</li>
<li><p><a href="#heading-backend-setup">Backend Setup</a></p>
</li>
<li><p><a href="#heading-brevo-configuration-setup">Brevo Configuration Setup</a></p>
</li>
<li><p><a href="#heading-creating-the-email-function">Creating the Email Function</a></p>
</li>
<li><p><a href="#heading-integrating-the-function-into-an-express-route">Integrating the Function into an Express Route</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To get the absolute most out of this tutorial, it’s important to have some basic knowledge of the following:</p>
<ul>
<li><p><strong>JavaScript and Node.js:</strong> Having a good fundamental understanding of how JS works on the server side will make it easier to follow along with the project.</p>
</li>
<li><p><strong>REST APIs:</strong> You should have a basic understanding of how HTTP requests (like POST and GET) work using native <code>fetch()</code> in Node.js.</p>
</li>
<li><p><strong>Express.js:</strong> A little background on creating basic server routes will be helpful, as we'll build a real-world controller.</p>
</li>
<li><p>A basic understanding of what <a href="https://nodemailer.com/">Nodemailer</a> is and how cloud hosting platforms (like Render or Heroku) operate.</p>
</li>
</ul>
<h2 id="heading-tools-well-be-using">Tools We’ll Be Using</h2>
<p>In one of my recent projects, I created a complex authentication flow where users needed an OTP (One Time Password) sent to their email to complete registration. I set up Nodemailer, linked my Gmail, and tested it on <code>localhost</code>. Within seconds, the emails arrived perfectly.</p>
<p>But when I deployed my backend to Render, the entire signup flow broke. After doing some deep digging, I found out why it broke and how to fix it permanently. And now that I know how it works, I wanted to share it with you all.</p>
<h2 id="heading-the-problem-nodemailer-and-smtp-blocking">The Problem: Nodemailer and SMTP Blocking</h2>
<p>So what exactly is the issue?</p>
<p>Nodemailer is a very popular Node.js module that lets you send emails efficiently. Usually, developers use it to connect to services like Gmail or Mailtrap using <strong>SMTP</strong> (Simple Mail Transfer Protocol). When your code tries to send an email, Nodemailer opens a connection to the mail server using Port <code>587</code> (for STARTTLS) or Port <code>465</code> (for SSL).</p>
<p>But cloud providers like Render, Heroku, DigitalOcean, and AWS face a massive daily battle against automated spammers. Malicious users often spin up thousands of free-tier servers specifically to blast out millions of spam emails. If a cloud provider allows this, their entire network IP address block will get blacklisted by Gmail, Outlook, and Yahoo.</p>
<p>To protect their network reputation, cloud providers enacted a heavy-handed, silent rule: <strong>All outbound traffic on Ports 25, 465, and 587 is strictly blocked on free and entry-level tiers.</strong></p>
<p>This means your server is literally trapped behind a firewall. If you check your server logs, you won't see an "Invalid Password" error. Instead, you'll see a timeout error that looks like this:</p>
<pre><code class="language-plaintext">Error: connect ETIMEDOUT 142.250.102.108:587
    at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1494:16)
</code></pre>
<p>Your code isn't broken – it's just being blocked at the network level!</p>
<h3 id="heading-the-modern-trap-domain-verification">The "Modern" Trap: Domain Verification</h3>
<p>When developers hit this wall, they often try modern API-based email services like Resend or SendGrid. These are amazing tools, but they introduce a new problem for beginners: <strong>Strict Domain Authentication.</strong></p>
<p>To use Resend in production, you must own a custom domain (like <code>yourname.com</code>) and configure DNS records (SPF, DKIM, and DMARC). If you don't own a domain, Resend's sandbox mode strictly restricts you to sending emails <em>only</em> to yourself. You can't send emails to your live users.</p>
<p>For a developer just trying to launch a portfolio project, buying a domain just to send test emails is a huge bottleneck.</p>
<h3 id="heading-the-ultimate-solution-brevo-and-http-apis">The Ultimate Solution: Brevo and HTTP APIs</h3>
<p>We need a solution that meets two criteria:</p>
<ol>
<li><p>It must bypass the Port <code>587</code> firewall.</p>
</li>
<li><p>It must let us send emails to <em>anyone</em> without forcing us to buy a custom domain.</p>
</li>
</ol>
<p>This is where the architectural difference between SMTP and REST APIs comes to the rescue. While SMTP is a dedicated protocol for routing mail, a REST API operates over standard web traffic using <strong>HTTPS (Port 443)</strong>. Cloud providers <em>can't</em> block Port 443, because doing so would prevent your server from fetching data from databases or functioning as a web server entirely.</p>
<p>Enter <strong>Brevo</strong> (formerly Sendinblue). Brevo is a powerful email platform that allows you to send emails via a standard REST API. Best of all, their free tier (300 emails/day) allows Single Sender Verification. You just verify your standard Gmail address, and they let you send to anyone!</p>
<p>By sending a JSON payload via HTTPS to Brevo's API, your server routes the traffic out of the unrestricted Port <code>443</code>, bypassing the Render firewall completely.</p>
<p>Now that you know the theory behind the tools we’ll be using, let’s move on to writing the code.</p>
<h2 id="heading-backend-setup">Backend Setup</h2>
<p>First things first, you have to set up your environment. If you don't already have Node.js installed on your computer, head to their <a href="https://nodejs.org/en">website</a> to download and install it.</p>
<p>Start by running <code>npm init -y</code> in your terminal. This creates the <code>package.json</code> file which manages your project and stores all the dependencies.</p>
<p>Next, run <code>npm install express dotenv</code>.</p>
<p>You might be used to installing <code>nodemailer</code> for your email tasks. But because we are going to use the native Node.js <code>fetch()</code> API to talk to the Brevo API, you actually don't need to install <em>any</em> heavy email libraries at all! We want to keep our backend as lightweight as possible.</p>
<h3 id="heading-brevo-configuration-setup">Brevo Configuration Setup</h3>
<p>Before you write the email function, you first need to configure Brevo to get access to your API key.</p>
<ol>
<li><p>Go to <a href="https://www.brevo.com/">Brevo.com</a> and create a free account.</p>
</li>
<li><p>During setup, they will ask you to add a <strong>Sender Email</strong>. Make sure you input your standard Gmail address. They will send you an email with a link to verify you own this address.</p>
</li>
<li><p>Once verified and inside the dashboard, click on your profile name in the top right corner, and select <strong>SMTP &amp; API</strong> from the dropdown menu.</p>
</li>
<li><p>Go to the <strong>API Keys</strong> tab and click <strong>Generate a new API key</strong>. Give it a name like "MyWebApp".</p>
</li>
</ol>
<p>Copy this generated key and store it safely in a <code>.env</code> file at the root of your project:</p>
<pre><code class="language-env"># .env file
EMAIL_USER = yourverifiedemail@gmail.com
BREVO_API_KEY = xkeysib-your-generated-api-key-goes-here
</code></pre>
<h3 id="heading-creating-the-email-function">Creating the Email Function</h3>
<p>Now that you’ve gotten your API key and set up your environment variables, all that remains is to start putting your backend code together.</p>
<p>Create a file named <code>utils/email.js</code>.</p>
<p>First, start by ensuring you can load your <code>.env</code> file so you can easily access the credentials you generated:</p>
<pre><code class="language-javascript">require("dotenv").config();

// We'll define the function to accept dynamic options
const sendEmail = async (options) =&gt; {
  const brevoApiKey = process.env.BREVO_API_KEY;
  const senderEmail = process.env.EMAIL_USER;

  // Validate that the keys actually exist
  if (!brevoApiKey || !senderEmail) {
    throw new Error("Missing Brevo credentials in environment variables.");
  }
</code></pre>
<p>Next on the line, you’ll need to structure your payload. This is the JSON object that tells Brevo exactly who is sending the email, who is receiving it, and what the content is. Here’s how you can do that:</p>
<pre><code class="language-javascript">  const payload = {
    sender: {
      name: "My Awesome Web App",
      email: senderEmail, // Must match your verified Brevo email
    },
    to: [
      {
        email: options.email, // The dynamic email address of the user receiving the email
      },
    ],
    subject: options.subject,
    htmlContent: options.html,
  };
</code></pre>
<p>In the code above, the <code>payload</code> object securely packages up your information. We pass in <code>options.email</code>, <code>options.subject</code>, and <code>options.html</code> so that we can reuse this single function for welcome emails, password resets, and notifications.</p>
<p>Now, create the actual network request that sends your data to the Brevo backend. We'll use the <code>POST</code> method. When the data is sent, it must be stringified into a JSON format.</p>
<pre><code class="language-javascript">  try {
    const response = await fetch("https://api.brevo.com/v3/smtp/email", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "api-key": brevoApiKey,
      },
      body: JSON.stringify(payload),
    });

    const result = await response.json();

    if (!response.ok) {
      throw new Error(`Brevo API Error: ${JSON.stringify(result)}`);
    }

    console.log(`Email successfully sent to ${options.email} via Brevo HTTP API!`);
  } catch (error) {
    console.error("Error details:", error.message);
  }
};

module.exports = sendEmail;
</code></pre>
<p>In the code above, after the payload is submitted, if the message is sent successfully, a success log will be displayed in your terminal. But if the message wasn’t successful – maybe due to a typo in your API key – an error message will be thrown to help you debug exactly what went wrong.</p>
<h3 id="heading-integrating-the-function-into-an-express-route">Integrating the Function into an Express Route</h3>
<p>At this point, you've successfully built a robust email function. Let's see how you would actually use this in a real Express application.</p>
<p>Create an <code>index.js</code> file and set up a simple Express server route:</p>
<pre><code class="language-javascript">const express = require("express");
const sendEmail = require("./utils/email");
const app = express();

app.use(express.json()); // Middleware to parse JSON request bodies

app.post("/api/signup", async (req, res) =&gt; {
  const { username, email } = req.body;

  // 1. Save user to database (skipped for brevity)
  
  // 2. Generate a random OTP
  const otp = Math.floor(100000 + Math.random() * 900000);

  // 3. Send the email using our new Brevo function
  try {
    await sendEmail({
      email: email,
      subject: "Welcome! Here is your Verification Code",
      html: `
        &lt;div style="font-family: sans-serif; text-align: center;"&gt;
          &lt;h2&gt;Welcome to My Awesome Web App, ${username}!&lt;/h2&gt;
          &lt;p&gt;Please use the verification code below to complete your registration:&lt;/p&gt;
          &lt;h1 style="color: #2563eb; letter-spacing: 5px;"&gt;${otp}&lt;/h1&gt;
          &lt;p&gt;This code will expire in 10 minutes.&lt;/p&gt;
        &lt;/div&gt;
      `,
    });

    res.status(201).json({ message: "User created and email sent!" });
  } catch (error) {
    res.status(500).json({ error: "Failed to send email." });
  }
});

app.listen(8000, () =&gt; {
  console.log("Server running on port 8000");
});
</code></pre>
<p>And that is it! You can now hit this <code>/api/signup</code> endpoint from your React or Vue frontend, and it will instantly fire off a beautifully formatted email via Brevo's REST API.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>As developers, encountering a bug that works locally but fails in production is a rite of passage. But the "Email Delivery Failed" timeout error is special. It teaches you that software engineering isn't just about writing clean syntax – it's about understanding the underlying infrastructure, network layers, and the security context of the environment your code runs in.</p>
<p>By swapping a protocol (SMTP) for an architectural pattern (REST API over HTTPS), you didn't just fix a bug. You successfully engineered a secure, free, and robust bypass around a cloud-level firewall without relying on heavy third-party NPM modules like Nodemailer.</p>
<p>If you've made it this far, I hope I've successfully shown you the importance of understanding network layers and how you can use HTTP APIs to send email messages directly from your web applications safely.</p>
<p>Thank you for reading!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Handle Stripe Webhooks Reliably with Background Jobs ]]>
                </title>
                <description>
                    <![CDATA[ You've set up Stripe. Checkout works. Customers can pay. But what happens after payment? The webhook handler is where most payment integrations silently break. Your server crashes halfway through gran ]]>
                </description>
                <link>https://www.freecodecamp.org/news/stripe-webhooks-background-jobs/</link>
                <guid isPermaLink="false">69e8f14f5d1c10710571b1ae</guid>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Software Engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Magnus Rødseth ]]>
                </dc:creator>
                <pubDate>Wed, 22 Apr 2026 16:03:27 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/460d0b4c-c95d-4356-a6df-a0c0c52b78b6.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You've set up Stripe. Checkout works. Customers can pay. But what happens <em>after</em> payment?</p>
<p>The webhook handler is where most payment integrations silently break. Your server crashes halfway through granting access. Your email service is down when you try to send the confirmation. Your database times out during a write.</p>
<p>Stripe retries the entire webhook, but your handler already sent the confirmation email before it crashed. Now the customer gets two emails and no access.</p>
<p>This article shows you how to fix this. You'll learn how to build webhook handlers that survive failures by splitting your post-payment logic into durable, independently retried steps. The pattern works for any multi-step webhook processing, not just Stripe.</p>
<p>Here's what you'll learn:</p>
<ul>
<li><p>Why Stripe webhooks fail silently in production</p>
</li>
<li><p>How a naïve inline handler breaks under real-world conditions</p>
</li>
<li><p>The pattern: webhook receives, validates, and enqueues (nothing more)</p>
</li>
<li><p>How to build a durable purchase flow with individually checkpointed steps</p>
</li>
<li><p>How to handle refunds and abandoned checkouts with the same pattern</p>
</li>
<li><p>How to test webhook handlers locally</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should be familiar with:</p>
<ul>
<li><p>Node.js and TypeScript</p>
</li>
<li><p>Basic Stripe integration (checkout sessions, webhooks)</p>
</li>
<li><p>SQL databases (the examples use PostgreSQL with Drizzle ORM)</p>
</li>
<li><p>npm or any Node.js package manager</p>
</li>
</ul>
<p>You don't need prior experience with Inngest or durable execution. This article explains both from scratch.</p>
<h3 id="heading-what-you-need-to-install">What You Need to Install</h3>
<p>If you want to run the code examples, install these packages:</p>
<pre><code class="language-bash">npm install inngest stripe drizzle-orm @react-email/components resend
</code></pre>
<p>You'll also need the <a href="https://stripe.com/docs/stripe-cli">Stripe CLI</a> for local webhook testing. Install it via Homebrew on macOS (<code>brew install stripe/stripe-cli/stripe</code>) or follow the instructions in Stripe's documentation for other platforms.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-stripe-webhooks-fail-silently">Why Stripe Webhooks Fail Silently</a></p>
</li>
<li><p><a href="#heading-the-naive-approach-and-why-it-breaks">The Naïve Approach (and Why It Breaks)</a></p>
</li>
<li><p><a href="#heading-the-pattern-webhook-to-event-to-durable-function">The Pattern: Webhook to Event to Durable Function</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-the-webhook-endpoint">How to Set Up the Webhook Endpoint</a></p>
</li>
<li><p><a href="#heading-how-to-build-a-durable-purchase-flow">How to Build a Durable Purchase Flow</a></p>
</li>
<li><p><a href="#heading-how-to-handle-refunds-with-the-same-pattern">How to Handle Refunds with the Same Pattern</a></p>
</li>
<li><p><a href="#heading-how-to-recover-abandoned-checkouts">How to Recover Abandoned Checkouts</a></p>
</li>
<li><p><a href="#heading-how-to-test-webhook-handlers-locally">How to Test Webhook Handlers Locally</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-why-stripe-webhooks-fail-silently">Why Stripe Webhooks Fail Silently</h2>
<p>The happy path is easy. A customer pays, Stripe sends a <code>checkout.session.completed</code> event to your server, and your handler processes it. In development, this works every time.</p>
<p>Production is different: Your webhook handler typically needs to do several things after a successful payment. It looks up the user in the database, records the purchase, sends a confirmation email, notifies the admin, grants access to the product (maybe via a GitHub invitation or an API key), and schedules follow-up emails. That's five or six operations involving three or four external services.</p>
<p>Here are the failure modes that will eventually hit your webhook handler:</p>
<h4 id="heading-1-your-server-crashes-mid-processing">1. Your server crashes mid-processing</h4>
<p>The database write succeeded, but the email never sent. Stripe retries the webhook, and your handler runs again.</p>
<p>Now you have a duplicate database entry or a unique constraint error that kills the retry.</p>
<h4 id="heading-2-an-external-service-is-temporarily-down">2. An external service is temporarily down</h4>
<p>Your email provider returns a 500. Your GitHub API call gets rate-limited. Your analytics service times out.</p>
<p>The webhook handler throws, and Stripe retries the entire thing. But the steps that already succeeded (the database write, the first email) run again.</p>
<h4 id="heading-3-the-handler-times-out">3. The handler times out</h4>
<p>Stripe expects a 2xx response within about 20 seconds. If your handler does too much work, Stripe marks it as failed and retries. Your handler may have partially completed before the timeout.</p>
<h4 id="heading-4-partial-completion-with-no-rollback">4. Partial completion with no rollback</h4>
<p>This is the worst failure mode. Steps 1 through 3 succeed. Step 4 fails. Stripe retries, and steps 1 through 3 run again.</p>
<p>The customer gets two confirmation emails. The database gets a duplicate record. But step 4 still fails because the underlying issue (a rate limit, a service outage) hasn't been resolved.</p>
<h4 id="heading-5-race-conditions-on-retry">5. Race conditions on retry</h4>
<p>Stripe can deliver the same event more than once even without a failure on your end. Network glitches, load balancer timeouts, and Stripe's own retry logic mean your handler must be prepared for duplicate deliveries. If your handler isn't idempotent at every step, duplicates compound the partial-completion problem.</p>
<p>Stripe's retry behavior is well-designed. It uses exponential backoff and retries up to dozens of times over several days. But Stripe retries the <em>entire webhook delivery</em>.</p>
<p>It has no way to know that your handler completed steps 1 through 3 and only needs to retry step 4. That distinction is your responsibility.</p>
<p>The core problem is that your webhook handler does too many things in a single request. Every external call is a potential failure point, and you have no checkpointing between them. When one fails, you lose track of which ones already succeeded.</p>
<h2 id="heading-the-naive-approach-and-why-it-breaks">The Naïve Approach (and Why It Breaks)</h2>
<p>Here's what a typical webhook handler looks like. I've seen hundreds of variations of this pattern across codebases, tutorials, and Stack Overflow answers:</p>
<pre><code class="language-typescript">app.post("/api/payments/webhook", async (req, res) =&gt; {
  const event = stripe.webhooks.constructEvent(
    req.body,
    req.headers["stripe-signature"],
    process.env.STRIPE_WEBHOOK_SECRET
  );

  if (event.type === "checkout.session.completed") {
    const session = event.data.object;

    // Step 1: Look up the user
    const user = await db.users.findOne({ id: session.metadata.userId });

    // Step 2: Record the purchase
    await db.purchases.insert({
      userId: user.id,
      stripeSessionId: session.id,
      amount: session.amount_total,
      status: "completed",
    });

    // Step 3: Send confirmation email
    await sendEmail({
      to: user.email,
      subject: "Purchase confirmed!",
      template: "purchase-confirmation",
    });

    // Step 4: Grant product access (GitHub repo invitation)
    await addCollaborator(user.githubUsername);

    // Step 5: Send access email
    await sendEmail({
      to: user.email,
      subject: "Your repository access is ready!",
      template: "repo-access",
    });

    // Step 6: Track analytics
    await analytics.track(user.id, "purchase_completed", {
      amount: session.amount_total,
    });
  }

  res.json({ received: true });
});
</code></pre>
<p>This looks clean. It reads top-to-bottom. Every tutorial teaches it this way.</p>
<p>Now walk through what happens when step 4 fails. Maybe GitHub's API is rate-limited and the <code>addCollaborator</code> call throws an error. Your handler returns a 500 to Stripe.</p>
<p>Here is the state after the failure:</p>
<ul>
<li><p>The user exists in the database (step 1 was just a lookup, no problem).</p>
</li>
<li><p>A purchase record was created (step 2 succeeded).</p>
</li>
<li><p>The confirmation email was sent (step 3 succeeded).</p>
</li>
<li><p>GitHub access was <strong>not</strong> granted (step 4 failed).</p>
</li>
<li><p>The access email was <strong>not</strong> sent (step 5 never ran).</p>
</li>
<li><p>Analytics were <strong>not</strong> tracked (step 6 never ran).</p>
</li>
</ul>
<p>Stripe retries the webhook. Your handler runs again from the top:</p>
<ul>
<li><p>Step 1: Looks up the user again. Fine.</p>
</li>
<li><p>Step 2: Tries to insert another purchase record. If you have a unique constraint on <code>stripeSessionId</code>, this throws. If you don't, you now have a duplicate.</p>
</li>
<li><p>Step 3: Sends the confirmation email again. The customer gets a second "Purchase confirmed!" email.</p>
</li>
<li><p>Step 4: Tries GitHub access again. Maybe it works this time, maybe not.</p>
</li>
<li><p>Steps 5-6: May or may not run depending on step 4.</p>
</li>
</ul>
<p>You can patch this with idempotency checks: "if purchase already exists, skip step 2." But now your handler is full of conditional logic for every step. And you still have the duplicate email problem, because there's no way to check "did I already send this email?" without building your own tracking system.</p>
<p>This approach doesn't scale. Every new step adds another failure mode, another idempotency check, and another edge case.</p>
<h2 id="heading-the-pattern-webhook-to-event-to-durable-function">The Pattern: Webhook to Event to Durable Function</h2>
<p>The fix is a separation of concerns. Your webhook handler should do exactly one thing: validate the incoming event and enqueue it for processing. Nothing else.</p>
<p>All the actual work (database writes, emails, API calls, analytics) moves into a durable background function where each step is individually checkpointed, retried, and tracked.</p>
<p>Here's the flow:</p>
<pre><code class="language-text">Stripe webhook
    |
    v
Webhook endpoint (validate signature, extract event, enqueue)
    |
    v
Background job system (receives event)
    |
    v
Durable function
    |-- Step 1: Look up user and purchase (checkpointed)
    |-- Step 2: Track analytics (checkpointed)
    |-- Step 3: Send confirmation email (checkpointed)
    |-- Step 4: Send admin notification (checkpointed)
    |-- Step 5: Grant GitHub access (checkpointed)
    |-- Step 6: Track GitHub access (checkpointed)
    |-- Step 7: Update purchase record (checkpointed)
    |-- Step 8: Send repo access email (checkpointed)
    |-- Step 9: Schedule follow-up sequence (checkpointed)
</code></pre>
<p>Each step wrapped in <code>step.run()</code> is a durable checkpoint. If step 5 fails:</p>
<ul>
<li><p>Steps 1 through 4 do <strong>not</strong> re-run. Their results are cached.</p>
</li>
<li><p>Step 5 retries independently, with its own retry counter.</p>
</li>
<li><p>Once step 5 succeeds, steps 6 through 9 continue.</p>
</li>
</ul>
<p>This is what "durable execution" means. The function's progress survives failures. You get step-level retries instead of function-level retries. No duplicate emails. No duplicate database writes. No partial completion.</p>
<p>I use <a href="https://www.inngest.com/">Inngest</a> for this. It's an event-driven durable execution platform that provides step-level checkpointing out of the box. You define functions with <code>step.run()</code> blocks, and Inngest handles retry logic, state persistence, and observability. No Redis, no worker processes, no custom retry code.</p>
<p>Other tools can achieve similar results (Temporal, for example), but Inngest's developer experience with TypeScript is what sold me. You write normal async functions. The <code>step.run()</code> wrapper is the only addition.</p>
<h2 id="heading-how-to-set-up-the-webhook-endpoint">How to Set Up the Webhook Endpoint</h2>
<p>Your webhook endpoint should be minimal. Validate the signature, extract the event data, send it to your background job system, and return a 200 immediately.</p>
<p>Here's the real webhook endpoint from my production codebase:</p>
<pre><code class="language-typescript">import { constructWebhookEvent } from "@/lib/payments";
import { inngest } from "@/lib/jobs";

app.post("/api/payments/webhook", async ({ request, set }) =&gt; {
  const body = await request.text();
  const sig = request.headers.get("stripe-signature");

  if (!sig) {
    set.status = 400;
    return { error: "Missing signature" };
  }

  try {
    const event = await constructWebhookEvent(body, sig);
    console.log(`[Webhook] Received ${event.type}`);

    if (event.type === "charge.refunded") {
      const charge = event.data.object;
      await inngest.send({
        name: "stripe/charge.refunded",
        data: {
          chargeId: charge.id,
          paymentIntentId: charge.payment_intent,
          amountRefunded: charge.amount_refunded,
          originalAmount: charge.amount,
          currency: charge.currency,
        },
      });
    }

    if (event.type === "checkout.session.expired") {
      const session = event.data.object;
      await inngest.send({
        name: "stripe/checkout.session.expired",
        data: {
          sessionId: session.id,
          customerEmail: session.customer_email,
        },
      });
    }

    return { received: true };
  } catch (error) {
    console.error("[Webhook] Stripe verification failed:", error);
    set.status = 400;
    return { error: "Webhook verification failed" };
  }
});
</code></pre>
<p>Notice what this handler does <strong>not</strong> do: it does not look up users, write to the database, send emails, or call external APIs. It validates the Stripe signature, extracts the relevant fields, and sends a typed event to Inngest. The entire handler completes in milliseconds.</p>
<p>The <code>constructWebhookEvent</code> function wraps Stripe's signature verification:</p>
<pre><code class="language-typescript">import Stripe from "stripe";

export async function constructWebhookEvent(
  payload: string | Buffer,
  signature: string
) {
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
  if (!webhookSecret) {
    throw new Error("STRIPE_WEBHOOK_SECRET is not set");
  }
  const client = new Stripe(process.env.STRIPE_SECRET_KEY);
  return client.webhooks.constructEventAsync(payload, signature, webhookSecret);
}
</code></pre>
<p>One critical detail: you must pass the <strong>raw request body</strong> (as a string or buffer) to Stripe's signature verification. If your framework parses the body as JSON before you can access the raw string, the signature check will fail. This is the number one cause of "webhook signature verification failed" errors.</p>
<p>The Inngest client setup is minimal:</p>
<pre><code class="language-typescript">import { Inngest } from "inngest";

export const inngest = new Inngest({
  id: "my-app",
});
</code></pre>
<p>For the purchase flow specifically, a different endpoint sends the event (the "claim" route that the frontend calls after the customer returns from Stripe checkout). But the principle is identical: validate, enqueue, return.</p>
<pre><code class="language-typescript">// After verifying payment status with Stripe
await inngest.send({
  name: "purchase/completed",
  data: {
    userId: session.user.id,
    tier,
    sessionId,
  },
});
</code></pre>
<h2 id="heading-how-to-build-a-durable-purchase-flow">How to Build a Durable Purchase Flow</h2>
<p>This is the core of the article. The <code>handlePurchaseCompleted</code> function processes a purchase after payment using 9 individually checkpointed steps. Every step is real production code.</p>
<p>The example below grants access to a private GitHub repository because that's what this particular product sells.</p>
<p>Your product's "grant access" step will be different: upgrading a user to a Pro membership, provisioning API credits, unlocking a course, or activating a subscription. The durable step pattern is the same regardless of what you're delivering.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a694d8d4dc9b42434c218f/935ca377-52ff-4fc2-8e97-98fb7712c896.png" alt="Durable purchase flow with 9 numbered steps, showing step 5 failing and retrying while steps 1 through 4 remain checkpointed" style="display: block;" width="5504" height="3072" loading="lazy">

<p>If step 5 fails (for example, the email provider is down), Inngest retries only step 5. Steps 1 through 4 are already checkpointed and don't re-execute. Steps 6 through 9 wait until step 5 succeeds.</p>
<pre><code class="language-typescript">import { eq } from "drizzle-orm";
import { createElement } from "react";

import { inngest } from "@/lib/jobs/client";
import { trackServerEvent } from "@/lib/analytics/server";
import { brand } from "@/lib/brand";
import { db, purchases, users } from "@/lib/db";
import {
  sendEmail,
  PurchaseConfirmationEmail,
  AdminPurchaseNotificationEmail,
  RepoAccessGrantedEmail,
} from "@/lib/email";
import { addCollaborator } from "@/lib/github";

export const handlePurchaseCompleted = inngest.createFunction(
  { id: "purchase-completed", triggers: [{ event: "purchase/completed" }] },
  async ({ event, step }) =&gt; {
    const { userId, tier, sessionId } = event.data;

    // Step 1: Look up user and purchase details
    const { user, purchase } = await step.run(
      "lookup-user-and-purchase",
      async () =&gt; {
        const userResult = await db
          .select({
            id: users.id,
            email: users.email,
            name: users.name,
            githubUsername: users.githubUsername,
          })
          .from(users)
          .where(eq(users.id, userId))
          .limit(1);

        const foundUser = userResult[0];
        if (!foundUser) {
          throw new Error(`User not found: ${userId}`);
        }

        const purchaseResult = await db
          .select({
            amount: purchases.amount,
            currency: purchases.currency,
            stripePaymentIntentId: purchases.stripePaymentIntentId,
          })
          .from(purchases)
          .where(eq(purchases.stripeCheckoutSessionId, sessionId))
          .limit(1);

        const foundPurchase = purchaseResult[0];

        return {
          user: foundUser,
          purchase: foundPurchase ?? {
            amount: 0,
            currency: "usd",
            stripePaymentIntentId: null,
          },
        };
      }
    );

    // Step 2: Track purchase completion in analytics
    await step.run("track-purchase-to-posthog", async () =&gt; {
      await trackServerEvent(userId, "purchase_completed_server", {
        tier,
        amount_cents: purchase.amount,
        currency: purchase.currency,
        stripe_session_id: sessionId,
      });
    });

    // Step 3: Send purchase confirmation to customer
    await step.run("send-purchase-confirmation", async () =&gt; {
      await sendEmail({
        to: user.email,
        subject: `Your purchase is confirmed!`,
        template: createElement(PurchaseConfirmationEmail, {
          amount: purchase.amount,
          currency: purchase.currency,
          customerEmail: user.email,
        }),
      });
    });

    // Step 4: Send admin notification
    await step.run("send-admin-notification", async () =&gt; {
      const adminEmail = process.env.ADMIN_EMAIL;
      if (!adminEmail) return;

      await sendEmail({
        to: adminEmail,
        subject: `New sale: ${user.email}`,
        template: createElement(AdminPurchaseNotificationEmail, {
          amount: purchase.amount,
          currency: purchase.currency,
          customerEmail: user.email,
          customerName: user.name,
          stripeSessionId: purchase.stripePaymentIntentId ?? sessionId,
        }),
      });
    });

    // Early return if user has no GitHub username
    if (!user.githubUsername) {
      return { success: true, userId, tier, githubAccessGranted: false };
    }

    // Step 5: Grant GitHub repository access
    const collaboratorResult = await step.run(
      "add-github-collaborator",
      async () =&gt; {
        return addCollaborator(user.githubUsername!);
      }
    );

    // Step 6: Track GitHub access granted
    await step.run("track-github-access", async () =&gt; {
      await trackServerEvent(userId, "github_access_granted", {
        tier,
        github_username: user.githubUsername,
        invitation_status: collaboratorResult.status,
      });
    });

    // Step 7: Update purchase record
    await step.run("update-purchase-record", async () =&gt; {
      await db
        .update(purchases)
        .set({
          githubAccessGranted: true,
          githubInvitationId: collaboratorResult.status,
          updatedAt: new Date(),
        })
        .where(eq(purchases.stripeCheckoutSessionId, sessionId));
    });

    // Step 8: Send repo access email
    await step.run("send-repo-access-email", async () =&gt; {
      await sendEmail({
        to: user.email,
        subject: `Your repository access is ready!`,
        template: createElement(RepoAccessGrantedEmail, {
          repoUrl: "https://github.com/your-org/your-repo",
        }),
      });
    });

    // Step 9: Schedule follow-up email sequence
    await step.run("schedule-follow-up", async () =&gt; {
      const purchaseRecord = await db
        .select({ id: purchases.id })
        .from(purchases)
        .where(eq(purchases.stripeCheckoutSessionId, sessionId))
        .limit(1);

      if (purchaseRecord[0]) {
        await inngest.send({
          name: "purchase/follow-up.scheduled",
          data: {
            userId,
            purchaseId: purchaseRecord[0].id,
            tier,
          },
        });
      }
    });

    return { success: true, userId, tier, githubAccessGranted: true };
  }
);
</code></pre>
<p>That's a lot of code. Let me walk through each step and explain why it's a separate checkpoint.</p>
<h3 id="heading-step-1-look-up-user-and-purchase">Step 1: Look Up User and Purchase</h3>
<pre><code class="language-typescript">const { user, purchase } = await step.run(
  "lookup-user-and-purchase",
  async () =&gt; {
    // ... database queries ...
    return { user: foundUser, purchase: foundPurchase };
  }
);
</code></pre>
<p>This step queries the database for the user and purchase records. If the database is temporarily unreachable, this step retries on its own.</p>
<p>The return value (<code>user</code> and <code>purchase</code>) is cached by Inngest. Every subsequent step can use <code>user.email</code>, <code>user.githubUsername</code>, and <code>purchase.amount</code> without re-querying the database.</p>
<p>If this step fails permanently (the user doesn't exist), it throws an error that halts the entire function. This is intentional. There's no point continuing if you can't find the user.</p>
<h3 id="heading-step-2-track-analytics">Step 2: Track Analytics</h3>
<pre><code class="language-typescript">await step.run("track-purchase-to-posthog", async () =&gt; {
  await trackServerEvent(userId, "purchase_completed_server", {
    tier,
    amount_cents: purchase.amount,
  });
});
</code></pre>
<p>Analytics tracking is a separate step because analytics services have their own failure modes (rate limits, outages, network timeouts). If PostHog is down, you don't want it to block the confirmation email.</p>
<p>In the production code, this step wraps the call in a try-catch so that a tracking failure doesn't halt the entire function. The analytics event is "nice to have," not critical.</p>
<h3 id="heading-step-3-send-purchase-confirmation-email">Step 3: Send Purchase Confirmation Email</h3>
<pre><code class="language-typescript">await step.run("send-purchase-confirmation", async () =&gt; {
  await sendEmail({
    to: user.email,
    subject: `Your purchase is confirmed!`,
    template: createElement(PurchaseConfirmationEmail, {
      amount: purchase.amount,
      currency: purchase.currency,
      customerEmail: user.email,
    }),
  });
});
</code></pre>
<p>This is the customer-facing confirmation. It's a separate step from the admin notification (step 4) because they're independent operations. If the admin email fails, the customer should still get their confirmation.</p>
<p>The <code>sendEmail</code> function uses Resend under the hood. If Resend returns a 500, this step retries. Because step 2 (analytics) already completed and is checkpointed, it won't re-run.</p>
<h3 id="heading-step-4-send-admin-notification">Step 4: Send Admin Notification</h3>
<pre><code class="language-typescript">await step.run("send-admin-notification", async () =&gt; {
  const adminEmail = process.env.ADMIN_EMAIL;
  if (!adminEmail) return;

  await sendEmail({
    to: adminEmail,
    subject: `New sale: ${user.email}`,
    template: createElement(AdminPurchaseNotificationEmail, { /* ... */ }),
  });
});
</code></pre>
<p>Admin notifications are completely independent from customer-facing operations. Separating them means a failure in one doesn't affect the other.</p>
<h3 id="heading-step-5-grant-github-access">Step 5: Grant GitHub Access</h3>
<pre><code class="language-typescript">const collaboratorResult = await step.run(
  "add-github-collaborator",
  async () =&gt; {
    return addCollaborator(user.githubUsername!);
  }
);
</code></pre>
<p>This is the step most likely to fail. GitHub's API has rate limits: it can time out, and the user's GitHub username might be invalid.</p>
<p>By making this its own step, a GitHub API failure doesn't trigger re-sends of the confirmation email (step 3) or the admin notification (step 4). Those steps are already checkpointed.</p>
<p>Notice the early return before this step: if the user has no GitHub username, the function returns early after step 4. The remaining steps only run when there's a GitHub account to grant access to.</p>
<h3 id="heading-step-6-track-github-access">Step 6: Track GitHub Access</h3>
<pre><code class="language-typescript">await step.run("track-github-access", async () =&gt; {
  await trackServerEvent(userId, "github_access_granted", {
    tier,
    github_username: user.githubUsername,
    invitation_status: collaboratorResult.status,
  });
});
</code></pre>
<p>This uses the <code>collaboratorResult</code> from step 5. Because <code>step.run()</code> caches return values, <code>collaboratorResult.status</code> is available here even if the function was interrupted and resumed between steps 5 and 6.</p>
<h3 id="heading-step-7-update-purchase-record">Step 7: Update Purchase Record</h3>
<pre><code class="language-typescript">await step.run("update-purchase-record", async () =&gt; {
  await db
    .update(purchases)
    .set({
      githubAccessGranted: true,
      githubInvitationId: collaboratorResult.status,
      updatedAt: new Date(),
    })
    .where(eq(purchases.stripeCheckoutSessionId, sessionId));
});
</code></pre>
<p>The database update happens after GitHub access is confirmed. You only mark <code>githubAccessGranted: true</code> after the collaborator invitation actually succeeded.</p>
<p>If you updated the record before granting access and the GitHub step failed, your database would say access was granted when it was not.</p>
<h3 id="heading-step-8-send-repo-access-email">Step 8: Send Repo Access Email</h3>
<pre><code class="language-typescript">await step.run("send-repo-access-email", async () =&gt; {
  await sendEmail({
    to: user.email,
    subject: `Your repository access is ready!`,
    template: createElement(RepoAccessGrantedEmail, {
      repoUrl: "https://github.com/your-org/your-repo",
    }),
  });
});
</code></pre>
<p>This email only sends after the GitHub invitation is confirmed (step 5) and the database is updated (step 7). The ordering matters. You don't want to tell the customer "your access is ready" if the invitation hasn't been sent.</p>
<h3 id="heading-step-9-schedule-follow-up-sequence">Step 9: Schedule Follow-Up Sequence</h3>
<pre><code class="language-typescript">await step.run("schedule-follow-up", async () =&gt; {
  const purchaseRecord = await db
    .select({ id: purchases.id })
    .from(purchases)
    .where(eq(purchases.stripeCheckoutSessionId, sessionId))
    .limit(1);

  if (purchaseRecord[0]) {
    await inngest.send({
      name: "purchase/follow-up.scheduled",
      data: {
        userId,
        purchaseId: purchaseRecord[0].id,
        tier,
      },
    });
  }
});
</code></pre>
<p>The final step triggers a separate Inngest function that handles the follow-up email sequence (day 7 onboarding tips, day 14 feedback request, day 30 testimonial request). This is an event-driven chain: one function completes and triggers another.</p>
<p>The follow-up function uses <code>step.sleep()</code> to wait between emails:</p>
<pre><code class="language-typescript">export const handlePurchaseFollowUp = inngest.createFunction(
  {
    id: "purchase-follow-up",
    triggers: [{ event: "purchase/follow-up.scheduled" }],
    cancelOn: [
      {
        event: "purchase/follow-up.cancelled",
        match: "data.purchaseId",
      },
    ],
  },
  async ({ event, step }) =&gt; {
    const { userId, purchaseId } = event.data;

    await step.sleep("wait-7-days", "7d");

    await step.run("send-day-7-email", async () =&gt; {
      // Check eligibility (user exists, not unsubscribed, not refunded)
      // Send onboarding tips email
    });

    await step.sleep("wait-14-days", "7d");

    await step.run("send-day-14-email", async () =&gt; {
      // Send feedback request email
    });

    await step.sleep("wait-30-days", "16d");

    await step.run("send-day-30-email", async () =&gt; {
      // Send testimonial request email
    });
  }
);
</code></pre>
<p>Notice the <code>cancelOn</code> option. If the purchase is refunded, you can send a <code>purchase/follow-up.cancelled</code> event, and the entire follow-up sequence stops. No stale emails sent to customers who asked for a refund.</p>
<h3 id="heading-why-each-step-must-be-separate">Why Each Step Must Be Separate</h3>
<p>The rule is simple: <strong>any operation that calls an external service or could fail independently should be its own step.</strong></p>
<p>A database query is a step because the database can be temporarily unreachable. An email send is a step because the email provider can return a 500. A GitHub API call is a step because it can be rate-limited.</p>
<p>If two operations always succeed or fail together (they share a single external call), they can be in the same step. But when in doubt, make it a separate step. The overhead is negligible, and the reliability gain is significant.</p>
<h2 id="heading-how-to-handle-refunds-with-the-same-pattern">How to Handle Refunds with the Same Pattern</h2>
<p>The refund flow follows the exact same durable step pattern. This function lives in the same file as <code>handlePurchaseCompleted</code>, so it shares the same imports (plus <code>removeCollaborator</code> from <code>@/lib/github</code> and the refund-specific email templates). Here's the <code>handleRefund</code> function:</p>
<pre><code class="language-typescript">export const handleRefund = inngest.createFunction(
  { id: "refund-processed", triggers: [{ event: "stripe/charge.refunded" }] },
  async ({ event, step }) =&gt; {
    const {
      chargeId,
      paymentIntentId,
      amountRefunded,
      originalAmount,
      currency,
    } = event.data;

    const isFullRefund = amountRefunded &gt;= originalAmount;

    // Step 1: Look up the purchase and user
    const { user, purchase } = await step.run(
      "lookup-purchase-by-payment-intent",
      async () =&gt; {
        const purchaseResult = await db
          .select({
            id: purchases.id,
            userId: purchases.userId,
            stripePaymentIntentId: purchases.stripePaymentIntentId,
            githubAccessGranted: purchases.githubAccessGranted,
          })
          .from(purchases)
          .where(eq(purchases.stripePaymentIntentId, paymentIntentId))
          .limit(1);

        const foundPurchase = purchaseResult[0];
        if (!foundPurchase) {
          return { user: null, purchase: null };
        }

        const userResult = await db
          .select({
            id: users.id,
            email: users.email,
            name: users.name,
            githubUsername: users.githubUsername,
          })
          .from(users)
          .where(eq(users.id, foundPurchase.userId))
          .limit(1);

        return { user: userResult[0] ?? null, purchase: foundPurchase };
      }
    );

    if (!purchase || !user) {
      return { success: false, reason: "no_matching_purchase" };
    }

    let accessRevoked = false;

    // Step 2: Revoke GitHub access (only for full refunds)
    if (isFullRefund &amp;&amp; user.githubUsername &amp;&amp; purchase.githubAccessGranted) {
      const revokeResult = await step.run(
        "revoke-github-access",
        async () =&gt; {
          return removeCollaborator(user.githubUsername!);
        }
      );
      accessRevoked = revokeResult.success;
    }

    // Step 3: Update purchase status
    await step.run("update-purchase-status", async () =&gt; {
      if (isFullRefund) {
        await db
          .update(purchases)
          .set({
            status: "refunded",
            githubAccessGranted: false,
            updatedAt: new Date(),
          })
          .where(eq(purchases.id, purchase.id));
      } else {
        await db
          .update(purchases)
          .set({
            status: "partially_refunded",
            updatedAt: new Date(),
          })
          .where(eq(purchases.id, purchase.id));
      }
    });

    // Step 4: Track refund in analytics
    await step.run("track-refund-event", async () =&gt; {
      await trackServerEvent(user.id, "refund_processed", {
        charge_id: chargeId,
        amount_cents: amountRefunded,
        original_amount_cents: originalAmount,
        currency,
        is_full_refund: isFullRefund,
        github_access_revoked: accessRevoked,
      });
    });

    // Step 5: Notify customer
    await step.run("send-customer-notification", async () =&gt; {
      if (isFullRefund) {
        await sendEmail({
          to: user.email,
          subject: "Your refund has been processed",
          template: createElement(AccessRevokedEmail, {
            customerEmail: user.email,
            refundAmount: amountRefunded,
            currency,
          }),
        });
      } else {
        await sendEmail({
          to: user.email,
          subject: "Your partial refund has been processed",
          template: createElement(PartialRefundEmail, {
            customerEmail: user.email,
            refundAmount: amountRefunded,
            originalAmount,
            currency,
          }),
        });
      }
    });

    // Step 6: Notify admin
    await step.run("send-admin-notification", async () =&gt; {
      const adminEmail = process.env.ADMIN_EMAIL;
      if (!adminEmail) return;

      await sendEmail({
        to: adminEmail,
        subject: `\({isFullRefund ? "Full" : "Partial"} refund: \){user.email}`,
        template: createElement(AdminRefundNotificationEmail, {
          customerEmail: user.email,
          customerName: user.name,
          githubUsername: user.githubUsername,
          refundAmount: amountRefunded,
          originalAmount,
          currency,
          stripeChargeId: chargeId,
          accessRevoked,
          isPartialRefund: !isFullRefund,
        }),
      });
    });

    return { success: true, accessRevoked, isFullRefund, userId: user.id };
  }
);
</code></pre>
<p>Three things are worth calling out in the refund flow.</p>
<ol>
<li><p><strong>Partial versus full refunds:</strong> The function distinguishes between the two using a simple comparison: <code>amountRefunded &gt;= originalAmount</code>. For a partial refund, the customer keeps access but the purchase status changes to <code>partially_refunded</code>. For a full refund, GitHub access is revoked and the status becomes <code>refunded</code>.  </p>
<p>This matters for your database integrity. Downstream systems (your dashboard, your analytics, your support tools) need accurate status values.</p>
</li>
<li><p><strong>Conditional step execution:</strong> The "revoke GitHub access" step only runs if three conditions are true: it's a full refund, the user has a GitHub username, and access was previously granted. Inngest handles this cleanly by skipping steps that don't need to run.  </p>
<p>This is more readable than deeply nested if-else blocks in a monolithic handler.</p>
</li>
<li><p><strong>Separate notifications for customers and admins:</strong> The customer gets a different email depending on whether the refund is full or partial. The admin always gets a detailed notification including the charge ID, the customer's GitHub username, and whether access was revoked.</p>
</li>
</ol>
<p>These are separate steps because a failure in the admin notification shouldn't block the customer notification. The customer's email is the higher priority.</p>
<h2 id="heading-how-to-recover-abandoned-checkouts">How to Recover Abandoned Checkouts</h2>
<p>Abandoned cart recovery is where the <code>step.sleep()</code> method shines. When a Stripe checkout session expires, you want to send a recovery email. But not immediately.</p>
<p>You want to wait an hour or so, giving the customer time to return on their own.</p>
<pre><code class="language-typescript">export const handleCheckoutExpired = inngest.createFunction(
  {
    id: "checkout-expired",
    triggers: [{ event: "stripe/checkout.session.expired" }],
  },
  async ({ event, step }) =&gt; {
    const { customerEmail, sessionId } = event.data;

    if (!customerEmail) {
      return { success: false, reason: "no_email" };
    }

    // Wait 1 hour before sending recovery email
    await step.sleep("wait-before-recovery-email", "1h");

    // Send abandoned cart email
    await step.run("send-abandoned-cart-email", async () =&gt; {
      const checkoutUrl = `https://yoursite.com/pricing`;

      await sendEmail({
        to: customerEmail,
        subject: "Your checkout is waiting",
        template: createElement(AbandonedCartEmail, {
          customerEmail,
          checkoutUrl,
        }),
      });
    });

    // Track the event
    await step.run("track-abandoned-cart", async () =&gt; {
      await trackServerEvent("anonymous", "abandoned_cart_email_sent", {
        customer_email: customerEmail,
        session_id: sessionId,
      });
    });

    return { success: true, customerEmail };
  }
);
</code></pre>
<p>The <code>step.sleep("wait-before-recovery-email", "1h")</code> line is the key. This pauses the function for one hour without consuming any compute resources.</p>
<p>Inngest handles the scheduling internally. After one hour, the function resumes and sends the email.</p>
<p>Without durable execution, you would need a cron job that queries a database for expired sessions, or a delayed job queue with Redis, or a <code>setTimeout</code> that gets lost when your server restarts. The <code>step.sleep()</code> approach is simpler, more readable, and more reliable.</p>
<p>There's also a guard at the top of the function. If Stripe doesn't have a customer email for the session (the customer closed the checkout before entering their email), the function returns early. There's no point scheduling a recovery email with no address to send it to.</p>
<p>This pattern scales to more complex recovery flows. You could add a second <code>step.sleep()</code> and send a follow-up recovery email three days later if the customer still hasn't purchased. You could check if the customer has since completed a purchase (by querying the database in a <code>step.run()</code>) and skip the email if they have.</p>
<p>Each additional step is one more <code>step.run()</code> or <code>step.sleep()</code> call. The function reads like a script describing your business logic, not a tangle of cron jobs and database flags.</p>
<h2 id="heading-how-to-test-webhook-handlers-locally">How to Test Webhook Handlers Locally</h2>
<p>Local testing is one of the biggest pain points with Stripe webhooks. You need Stripe to send events to your local machine, and you need your background job system running to process them. Here's the setup.</p>
<h3 id="heading-how-to-forward-stripe-events-locally">How to Forward Stripe Events Locally</h3>
<p>Install the <a href="https://stripe.com/docs/stripe-cli">Stripe CLI</a> and forward webhook events to your local server:</p>
<pre><code class="language-bash">stripe listen --forward-to localhost:3000/api/payments/webhook
</code></pre>
<p>The CLI prints a webhook signing secret (starting with <code>whsec_</code>). Set this as your <code>STRIPE_WEBHOOK_SECRET</code> environment variable for local development.</p>
<p>You can trigger test events directly:</p>
<pre><code class="language-bash">stripe trigger checkout.session.completed
stripe trigger charge.refunded
stripe trigger checkout.session.expired
</code></pre>
<h3 id="heading-how-to-run-the-inngest-dev-server">How to Run the Inngest Dev Server</h3>
<p>Inngest provides a local dev server that shows you every function execution, every step, and every retry in real time:</p>
<pre><code class="language-bash">npx inngest-cli@latest dev -u http://localhost:3000/api/inngest
</code></pre>
<p>The <code>-u</code> flag tells the Inngest dev server where your application is running so it can discover your functions. Open <code>http://localhost:8288</code> in your browser to see the Inngest dashboard.</p>
<h3 id="heading-how-to-watch-step-execution">How to Watch Step Execution</h3>
<p>The Inngest dev dashboard is where the durable execution pattern really clicks. When you trigger a Stripe event, you can see:</p>
<ol>
<li><p>The event arriving in the "Events" tab.</p>
</li>
<li><p>The function triggering in the "Runs" tab.</p>
</li>
<li><p>Each step executing one by one, with its input, output, and duration.</p>
</li>
<li><p>If a step fails, you see the error and the retry attempt.</p>
</li>
</ol>
<p>This visibility is something you don't get with inline webhook handlers. When a customer reports "I paid but didn't get access," you can look up the function run in the Inngest dashboard and see exactly which step failed and why. That kind of observability is invaluable in production.</p>
<h3 id="heading-how-to-simulate-failures">How to Simulate Failures</h3>
<p>To test the retry behavior, you can intentionally make a step fail. For example, temporarily throw an error in the "add-github-collaborator" step:</p>
<pre><code class="language-typescript">const collaboratorResult = await step.run(
  "add-github-collaborator",
  async () =&gt; {
    throw new Error("Simulated GitHub API failure");
  }
);
</code></pre>
<p>In the Inngest dashboard, you'll see:</p>
<ul>
<li><p>Steps 1 through 4 succeed and their results are cached.</p>
</li>
<li><p>Step 5 fails and is retried according to the retry policy.</p>
</li>
<li><p>Steps 6 through 9 remain pending until step 5 succeeds.</p>
</li>
</ul>
<p>Remove the thrown error, and on the next retry, step 5 succeeds. Steps 6 through 9 then execute in sequence, while steps 1 through 4 aren't re-executed. This is the checkpoint behavior in action.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The pattern for reliable Stripe webhooks comes down to one principle: <strong>separate receiving from processing.</strong></p>
<p>Your webhook endpoint validates the Stripe signature and sends a typed event to a background job system. That's all it does. The processing happens in a durable function where each step is individually checkpointed and retried.</p>
<p>Here's what this gives you:</p>
<ul>
<li><p><strong>No duplicate emails:</strong> A step that already succeeded doesn't re-run.</p>
</li>
<li><p><strong>No partial state:</strong> If step 5 fails, steps 1 through 4 are preserved and step 5 retries independently.</p>
</li>
<li><p><strong>Full observability:</strong> You can see exactly which step failed and why, for every function run.</p>
</li>
<li><p><strong>Built-in delayed execution:</strong> <code>step.sleep()</code> handles recovery emails and follow-up sequences without cron jobs.</p>
</li>
<li><p><strong>Composable workflows:</strong> One function can trigger another via events, creating chains like purchase completion leading to a 30-day follow-up sequence.</p>
</li>
</ul>
<p>This pattern isn't limited to Stripe. Any multi-step webhook processing benefits from durable execution: GitHub webhooks that trigger CI pipelines, Resend webhooks that track email delivery, or calendar webhooks that sync across services.</p>
<p>The principle is the same: Validate. Enqueue. Process durably.</p>
<p>I've used this pattern in production for <a href="https://eden-stack.com?utm_source=freecodecamp&amp;utm_medium=article&amp;utm_campaign=stripe-webhooks-background-jobs">Eden Stack</a>, where the purchase flow handles everything from payment confirmation to GitHub repository access grants to multi-week email sequences. The 9-step purchase function has processed every payment without a single missed step or duplicate email.</p>
<p>If you're building a SaaS with Stripe, start with the webhook endpoint pattern from this article. Keep the endpoint thin and move the processing into durable steps. You'll save yourself from the 3 AM debugging session when a customer says "I paid but nothing happened."</p>
<p>If you want the complete Stripe webhook and Inngest integration pre-built with purchase flows, refund handling, and follow-up email sequences ready to go, <a href="https://eden-stack.com?utm_source=freecodecamp&amp;utm_medium=article&amp;utm_campaign=stripe-webhooks-background-jobs">Eden Stack</a> includes everything from this article alongside 30+ additional production-tested patterns.</p>
<p><em>Magnus Rodseth builds AI-native applications and is the creator of</em> <a href="https://eden-stack.com?utm_source=freecodecamp&amp;utm_medium=article&amp;utm_campaign=stripe-webhooks-background-jobs"><em>Eden Stack</em></a><em>, a production-ready starter kit with 30+ Claude skills encoding production patterns for AI-native SaaS development.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Implement Token Bucket Rate Limiting with FastAPI ]]>
                </title>
                <description>
                    <![CDATA[ APIs power everything from mobile apps to enterprise platforms, quietly handling millions of requests per day. Without safeguards, a single misconfigured client or a burst of automated traffic can ove ]]>
                </description>
                <link>https://www.freecodecamp.org/news/token-bucket-rate-limiting-fastapi/</link>
                <guid isPermaLink="false">69c6f8747cf270651055571c</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ratelimit ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Prosper Ugbovo ]]>
                </dc:creator>
                <pubDate>Fri, 27 Mar 2026 21:36:52 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/fba3d4a6-faca-429a-8e16-a3e9778d2cf8.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>APIs power everything from mobile apps to enterprise platforms, quietly handling millions of requests per day. Without safeguards, a single misconfigured client or a burst of automated traffic can overwhelm your service, degrading performance for everyone.</p>
<p>Rate limiting prevents this. It controls how many requests a client can make within a given timeframe, protecting your infrastructure from both intentional abuse and accidental overload.</p>
<p>Among the several algorithms used for rate limiting, the <strong>Token Bucket</strong> stands out for its balance of simplicity and flexibility. Unlike fixed window counters that reset abruptly, the Token Bucket allows short bursts of traffic while still enforcing a sustainable long-term rate. This makes it a practical choice for APIs where clients occasionally need to send a quick flurry of requests without being penalized.</p>
<p>In this guide, you'll implement a Token Bucket rate limiter in a FastAPI application. You'll build the algorithm from scratch as a Python class, wire it into FastAPI as middleware with per-user tracking, add standard rate limit headers to your responses, and test everything with a simple script. By the end, you'll have a working rate limiter you can drop into any FastAPI project.</p>
<h3 id="heading-what-well-cover">What we'll cover:</h3>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-understanding-the-token-bucket-algorithm">Understanding the Token Bucket Algorithm</a></p>
</li>
<li><p><a href="#heading-setting-up-the-fastapi-project">Setting Up the FastAPI Project</a></p>
</li>
<li><p><a href="#heading-implementing-the-token-bucket-class">Implementing the Token Bucket Class</a></p>
</li>
<li><p><a href="#heading-adding-peruser-rate-limiting-middleware">Adding Per-User Rate Limiting Middleware</a></p>
</li>
<li><p><a href="#heading-testing-the-rate-limiter">Testing the Rate Limiter</a></p>
</li>
<li><p><a href="#heading-where-rate-limiting-fits-in-your-architecture">Where Rate Limiting Fits in Your Architecture</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow this tutorial, you'll need:</p>
<ul>
<li><p><strong>Python 3.9 or later</strong> installed on your machine. You can verify your version by running <code>python --version</code>.</p>
</li>
<li><p><strong>Familiarity with Python</strong> and basic knowledge of how HTTP APIs work.</p>
</li>
<li><p><strong>A text editor</strong> such as VS Code, Vim, or any editor you prefer.</p>
</li>
</ul>
<h2 id="heading-understanding-the-token-bucket-algorithm">Understanding the Token Bucket Algorithm</h2>
<p>Before writing code, it helps to understand the mechanism you'll be building.</p>
<p>The Token Bucket algorithm models rate limiting with two simple concepts: a <strong>bucket</strong> that holds tokens, and a <strong>refill process</strong> that adds tokens at a steady rate.</p>
<p>Here is how it works:</p>
<ol>
<li><p>The bucket starts full, holding a fixed maximum number of tokens (the capacity).</p>
</li>
<li><p>Each incoming request costs one token. If the bucket has tokens available, the request is allowed, and one token is removed.</p>
</li>
<li><p>If the bucket is empty, the request is rejected with a <code>429 Too Many Requests</code> response.</p>
</li>
<li><p>Tokens are added back to the bucket at a constant refill rate, regardless of whether requests are coming in. The bucket never exceeds its maximum capacity.</p>
</li>
</ol>
<p>The capacity determines how large a burst the system absorbs. The refill rate defines the sustained throughput. For example, a bucket with a capacity of 10 and a refill rate of 2 tokens per second allows a client to fire 10 requests instantly, but after that, they can only make 2 requests per second until the bucket refills.</p>
<p>This two-parameter design gives you precise control:</p>
<table>
<thead>
<tr>
<th>Parameter</th>
<th>Controls</th>
<th>Example</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Capacity</strong> (max tokens)</td>
<td>Maximum burst size</td>
<td>10 tokens = 10 requests at once</td>
</tr>
<tr>
<td><strong>Refill rate</strong></td>
<td>Sustained throughput</td>
<td>2 tokens/sec = 2 requests/sec long-term</td>
</tr>
<tr>
<td><strong>Refill interval</strong></td>
<td>Granularity of refill</td>
<td>1.0 sec = tokens added every second</td>
</tr>
</tbody></table>
<p>Compared to other rate-limiting algorithms:</p>
<ul>
<li><p><strong>Fixed Window</strong> counters reset at hard boundaries (for example, every minute), which can allow double the intended rate at window edges. The Token Bucket has no such boundary.</p>
</li>
<li><p><strong>Sliding Window</strong> counters are more accurate but more complex to implement and maintain.</p>
</li>
<li><p><strong>Leaky Bucket</strong> processes requests at a fixed rate and queues the rest. The Token Bucket is similar, but allows bursts instead of forcing a constant pace.</p>
</li>
</ul>
<p>The Token Bucket is widely used in production systems. AWS API Gateway, NGINX, and Stripe all use variations of it.</p>
<h2 id="heading-setting-up-the-fastapi-project">Setting Up the FastAPI Project</h2>
<p>Create a project directory and install the dependencies:</p>
<pre><code class="language-shell">mkdir fastapi-ratelimit &amp;&amp; cd fastapi-ratelimit
</code></pre>
<p>Create and activate a virtual environment:</p>
<pre><code class="language-shell">python -m venv venv
</code></pre>
<p>On Linux/macOS:</p>
<pre><code class="language-shell">source venv/bin/activate
</code></pre>
<p>On Windows:</p>
<pre><code class="language-shell">venv\Scripts\activate
</code></pre>
<p>Install FastAPI and Uvicorn:</p>
<pre><code class="language-shell">pip install fastapi uvicorn
</code></pre>
<p>Create the project file structure:</p>
<pre><code class="language-plaintext">fastapi-ratelimit/
├── main.py
└── ratelimiter.py
</code></pre>
<p>Create <code>main.py</code> with a minimal FastAPI application:</p>
<pre><code class="language-python">from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def root():
    return {"message": "Hello, world!"}
</code></pre>
<p>Start the server to verify the setup:</p>
<pre><code class="language-shell">uvicorn main:app --reload
</code></pre>
<p>You should see output similar to:</p>
<pre><code class="language-plaintext">INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process
</code></pre>
<p>Open in your browser <a href="http://127.0.0.1:8000">http://127.0.0.1:8000</a> or run curl <a href="http://127.0.0.1:8000">http://127.0.0.1:8000</a>. You should receive:</p>
<pre><code class="language-json">{"message": "Hello, world!"}
</code></pre>
<p>With the project running, you can move on to building the rate limiter.</p>
<h2 id="heading-implementing-the-token-bucket-class">Implementing the Token Bucket Class</h2>
<p>Open <code>ratelimiter.py</code> in your editor and add the following code. This class implements the Token Bucket algorithm with thread-safe operations:</p>
<pre><code class="language-python">import time
import threading


class TokenBucket:
    """
    Token Bucket rate limiter.

    Each bucket starts full at `max_tokens` and refills `refill_rate`
    tokens every `interval` seconds, up to the maximum capacity.
    """

    def __init__(self, max_tokens: int, refill_rate: int, interval: float):
        """
        Initialize a new Token Bucket.

        :param max_tokens: Maximum number of tokens the bucket can hold (burst capacity).
        :param refill_rate: Number of tokens added per refill interval.
        :param interval: Time in seconds between refills.
        """
        assert max_tokens &gt; 0, "max_tokens must be positive"
        assert refill_rate &gt; 0, "refill_rate must be positive"
        assert interval &gt; 0, "interval must be positive"

        self.max_tokens = max_tokens
        self.refill_rate = refill_rate
        self.interval = interval

        self.tokens = max_tokens
        self.refilled_at = time.time()
        self.lock = threading.Lock()

    def _refill(self):
        """Add tokens based on elapsed time since the last refill."""
        now = time.time()
        elapsed = now - self.refilled_at

        if elapsed &gt;= self.interval:
            num_refills = int(elapsed // self.interval)
            self.tokens = min(
                self.max_tokens,
                self.tokens + num_refills * self.refill_rate
            )
            # Advance the timestamp by the number of full intervals consumed,
            # not to `now`, so partial intervals aren't lost.
            self.refilled_at += num_refills * self.interval

    def allow_request(self, tokens: int = 1) -&gt; bool:
        """
        Attempt to consume `tokens` from the bucket.

        Returns True if the request is allowed, False if the bucket
        does not have enough tokens.
        """
        with self.lock:
            self._refill()

            if self.tokens &gt;= tokens:
                self.tokens -= tokens
                return True
            return False

    def get_remaining(self) -&gt; int:
        """Return the current number of available tokens."""
        with self.lock:
            self._refill()
            return self.tokens

    def get_reset_time(self) -&gt; float:
        """Return the Unix timestamp when the next refill occurs."""
        with self.lock:
            return self.refilled_at + self.interval
</code></pre>
<p>The class has three public methods:</p>
<ul>
<li><p><code>allow_request()</code> is the core method. It refills tokens based on elapsed time, then tries to consume one. It returns <code>True</code> if the request is allowed, <code>False</code> if the bucket is empty.</p>
</li>
<li><p><code>get_remaining()</code> returns the number of tokens the client has left. You will use this for response headers.</p>
</li>
<li><p><code>get_reset_time()</code> returns when the next token will be added. This is also exposed in response headers.</p>
</li>
</ul>
<p>The <code>threading.Lock</code> ensures that concurrent requests don't create race conditions when reading or modifying the token count. This is important because FastAPI runs request handlers concurrently.</p>
<p><strong>Note:</strong> This implementation stores bucket state in memory. If you restart the server, all buckets reset. For persistence across restarts or multiple server instances, you would store token counts in Redis or a similar external store. The in-memory approach is sufficient for single-instance deployments.</p>
<h2 id="heading-adding-per-user-rate-limiting-middleware">Adding Per-User Rate Limiting Middleware</h2>
<p>A single global bucket would throttle all users together. One heavy user could exhaust the limit for everyone. Instead, you'll assign a separate bucket to each user, identified by their IP address.</p>
<p>Add the following to <code>ratelimiter.py</code>, below the <code>TokenBucket</code> class:</p>
<pre><code class="language-python">from collections import defaultdict


class RateLimiterStore:
    """
    Manages per-user Token Buckets.

    Each unique client key (e.g., IP address) gets its own bucket
    with identical parameters.
    """

    def __init__(self, max_tokens: int, refill_rate: int, interval: float):
        self.max_tokens = max_tokens
        self.refill_rate = refill_rate
        self.interval = interval
        self._buckets: dict[str, TokenBucket] = {}
        self._lock = threading.Lock()

    def get_bucket(self, key: str) -&gt; TokenBucket:
        """
        Return the TokenBucket for a given client key.
        Creates a new bucket if one does not exist yet.
        """
        with self._lock:
            if key not in self._buckets:
                self._buckets[key] = TokenBucket(
                    max_tokens=self.max_tokens,
                    refill_rate=self.refill_rate,
                    interval=self.interval,
                )
            return self._buckets[key]
</code></pre>
<p>Now open <code>main.py</code> and replace its contents with the full application, including the rate-limiting middleware:</p>
<pre><code class="language-python">import time

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

from ratelimiter import RateLimiterStore

app = FastAPI()

# Configure rate limits: 10 requests burst, 2 tokens added every 1 second.
limiter = RateLimiterStore(max_tokens=10, refill_rate=2, interval=1.0)


@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
    """
    Middleware that enforces per-IP rate limiting on every request.
    Adds standard rate limit headers to every response.
    """
    # Identify the client by IP address.
    client_ip = request.client.host
    bucket = limiter.get_bucket(client_ip)

    # Check if the client has tokens available.
    if not bucket.allow_request():
        retry_after = bucket.get_reset_time() - time.time()
        return JSONResponse(
            status_code=429,
            content={"detail": "Too many requests. Try again later."},
            headers={
                "Retry-After": str(max(1, int(retry_after))),
                "X-RateLimit-Limit": str(bucket.max_tokens),
                "X-RateLimit-Remaining": str(bucket.get_remaining()),
                "X-RateLimit-Reset": str(int(bucket.get_reset_time())),
            },
        )

    # Request is allowed. Process it and add rate limit headers to the response.
    response = await call_next(request)
    response.headers["X-RateLimit-Limit"] = str(bucket.max_tokens)
    response.headers["X-RateLimit-Remaining"] = str(bucket.get_remaining())
    response.headers["X-RateLimit-Reset"] = str(int(bucket.get_reset_time()))
    return response


@app.get("/")
async def root():
    return {"message": "Hello, world!"}


@app.get("/data")
async def get_data():
    return {"data": "Some important information"}


@app.get("/health")
async def health():
    return {"status": "ok"}
</code></pre>
<p>The middleware does the following on every incoming request:</p>
<ol>
<li><p>Extracts the client's IP address from <code>request.client.host</code>.</p>
</li>
<li><p>Retrieves (or creates) that client's Token Bucket from the store.</p>
</li>
<li><p>Calls <code>allow_request()</code>. If the bucket is empty, it returns a <code>429</code> response with a <code>Retry-After</code> header telling the client how long to wait.</p>
</li>
<li><p>If tokens are available, it processes the request normally and attaches rate limit headers to the response.</p>
</li>
</ol>
<p>The three <code>X-RateLimit-*</code> headers follow a <a href="https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/">widely adopted convention</a>:</p>
<table>
<thead>
<tr>
<th>Header</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><code>X-RateLimit-Limit</code></td>
<td>Maximum burst capacity (max tokens)</td>
</tr>
<tr>
<td><code>X-RateLimit-Remaining</code></td>
<td>Tokens left in the current bucket</td>
</tr>
<tr>
<td><code>X-RateLimit-Reset</code></td>
<td>Unix timestamp when the next refill occurs</td>
</tr>
</tbody></table>
<p>These headers allow well-behaved clients to self-throttle before hitting the limit.</p>
<h2 id="heading-testing-the-rate-limiter">Testing the Rate Limiter</h2>
<p>Restart the server if it's not already running:</p>
<pre><code class="language-shell">uvicorn main:app --reload
</code></pre>
<h3 id="heading-manual-testing-with-curl">Manual Testing with curl</h3>
<p>Manual testing with <code>curl</code> is useful during development when you want to quickly verify that your middleware is working. A single request lets you confirm that the rate limit headers are present, the values are correct, and one token is consumed as expected.</p>
<p>This approach is fast and requires no additional setup, making it ideal for spot-checking your configuration after making changes.</p>
<p>Send a single request and inspect the response:</p>
<pre><code class="language-shell">curl -i http://127.0.0.1:8000/data
</code></pre>
<p>You should see a <code>200</code> response with headers like:</p>
<pre><code class="language-plaintext">HTTP/1.1 200 OK
x-ratelimit-limit: 10
x-ratelimit-remaining: 9
x-ratelimit-reset: 1739836801
</code></pre>
<h3 id="heading-automated-burst-test">Automated Burst Test</h3>
<p>While <code>curl</code> confirms that the rate limiter is active, it can't verify that the limiter actually blocks requests when the bucket is empty. For that, you need to send requests faster than the refill rate and observe the <code>429</code> responses. An automated burst test is essential before deploying to production, after changing your bucket parameters, or when you need to verify both the blocking and refill behavior.</p>
<p>Create a file called <code>test_ratelimit.py</code> in your project directory:</p>
<pre><code class="language-python">import requests
import time


def test_burst():
    """Send 15 rapid requests to trigger the rate limit."""
    url = "http://127.0.0.1:8000/data"
    results = []

    for i in range(15):
        response = requests.get(url)
        remaining = response.headers.get("X-RateLimit-Remaining", "N/A")
        results.append((i + 1, response.status_code, remaining))
        print(f"Request {i+1:2d} | Status: {response.status_code} | Remaining: {remaining}")

    print()

    allowed = sum(1 for _, status, _ in results if status == 200)
    blocked = sum(1 for _, status, _ in results if status == 429)
    print(f"Allowed: {allowed}, Blocked: {blocked}")


def test_refill():
    """Exhaust tokens, wait for a refill, then confirm requests succeed again."""
    url = "http://127.0.0.1:8000/data"

    print("\n--- Exhausting tokens ---")
    for i in range(12):
        response = requests.get(url)
        print(f"Request {i+1:2d} | Status: {response.status_code}")

    print("\n--- Waiting 3 seconds for refill ---")
    time.sleep(3)

    print("\n--- Sending requests after refill ---")
    for i in range(5):
        response = requests.get(url)
        remaining = response.headers.get("X-RateLimit-Remaining", "N/A")
        print(f"Request {i+1:2d} | Status: {response.status_code} | Remaining: {remaining}")


if __name__ == "__main__":
    print("=== Burst Test ===")
    test_burst()

    # Allow bucket to refill before next test
    time.sleep(6)

    print("\n=== Refill Test ===")
    test_refill()
</code></pre>
<p>Install the <code>requests</code> library if you don't have it:</p>
<pre><code class="language-shell">pip install requests
</code></pre>
<p>Run the test:</p>
<pre><code class="language-shell">python test_ratelimit.py
</code></pre>
<p>You should see output similar to:</p>
<pre><code class="language-output">=== Burst Test ===
Request  1 | Status: 200 | Remaining: 9
Request  2 | Status: 200 | Remaining: 8
Request  3 | Status: 200 | Remaining: 7
...
Request 10 | Status: 200 | Remaining: 0
Request 11 | Status: 429 | Remaining: 0
Request 12 | Status: 429 | Remaining: 0
...
Request 15 | Status: 429 | Remaining: 0

Allowed: 10, Blocked: 5
</code></pre>
<p>The first 10 requests succeed (one token each from the full bucket). Requests 11 through 15 are rejected because the bucket is empty. The refill test then confirms that after waiting, tokens reappear and requests succeed again.</p>
<p><strong>Note:</strong> The exact split between allowed and blocked requests may vary slightly due to timing. Tokens may refill between rapid requests. This is expected behavior.</p>
<h2 id="heading-where-rate-limiting-fits-in-your-architecture">Where Rate Limiting Fits in Your Architecture</h2>
<p>The implementation in this tutorial runs inside your application process, which is the simplest approach and works well for single-instance deployments. In larger systems, rate limiting typically appears at multiple layers:</p>
<ul>
<li><p><strong>API gateway level</strong> (NGINX, Kong, Traefik, Envoy): A coarse global rate limit applied to all traffic before it reaches your application. This protects against large-scale abuse and DDoS.</p>
</li>
<li><p><strong>Application level</strong> (this tutorial): Fine-grained per-user or per-endpoint limits inside your service. This is useful for enforcing different quotas on different API tiers.</p>
</li>
<li><p><strong>Both</strong>: Many production systems combine a gateway-level global limiter with an in-app per-user limiter. The gateway catches the flood and the application enforces business rules.</p>
</li>
</ul>
<p>For multi-instance deployments (multiple server processes behind a load balancer), the in-memory <code>RateLimiterStore</code> won't share state across instances. In that case, replace the in-memory dictionary with Redis. The Token Bucket logic stays the same – only the storage layer changes.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this guide, you built a Token Bucket rate limiter from scratch and integrated it into a FastAPI application with per-user tracking and standard rate limit response headers. You also tested the implementation to verify that burst capacity and refill behavior work as expected.</p>
<p>The Token Bucket algorithm gives you two straightforward controls, capacity for burst tolerance and refill rate for sustained throughput, which cover the vast majority of rate-limiting needs.</p>
<p>From here, you can extend this foundation by:</p>
<ul>
<li><p>Replacing the in-memory store with Redis for multi-instance deployments.</p>
</li>
<li><p>Applying different rate limits per endpoint by creating separate <code>RateLimiterStore</code> instances.</p>
</li>
<li><p>Using authenticated user IDs instead of IP addresses for more accurate client identification.</p>
</li>
<li><p>Adding metrics and logging to track how often clients are being throttled.</p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Voice-Powered AI Application with the Web Speech API ]]>
                </title>
                <description>
                    <![CDATA[ The Web Speech API is a web browser API that enables web applications to use sound as data in their operations. With the API, web apps can transcribe the speech in sound input and also synthesise spee ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-voice-powered-ai-application-with-the-web-speech-api/</link>
                <guid isPermaLink="false">69c5a2af10e664c5da34709b</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Orim Dominic Adah ]]>
                </dc:creator>
                <pubDate>Thu, 26 Mar 2026 21:18:39 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d6c77704-8ad6-4852-8a10-6656c76a34f4.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The <a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API">Web Speech API</a> is a web browser API that enables web applications to use sound as data in their operations. With the API, web apps can transcribe the speech in sound input and also synthesise speech from text.</p>
<p>This guide shows you how to build a full-stack web application that:</p>
<ul>
<li><p>Accepts audio input and transcribes the speech in it</p>
</li>
<li><p>Prompts an AI agent with the transcription</p>
</li>
<li><p>Displays the AI response on the UI</p>
</li>
</ul>
<p>The application you'll build will be a simplified version of the <strong>Use Voice</strong> feature on AI chat applications highlighted in the image below:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e28b713f978a0e2cd2b763/7adc60ff-cedb-48bc-a5c9-6e913dd3cc60.png" alt="Use voice feature of AI chat applications" style="display: block;" width="700" height="390" loading="lazy">

<p>By practising along with this article, you'll learn how to:</p>
<ul>
<li><p>Build a frontend application that uses the <a href="https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition">SpeechRecognition</a> API to accept voice input and transcribe it</p>
</li>
<li><p>Build a backend app that prompts an AI assistant of your choice and sends a response back to clients</p>
</li>
<li><p>Connect both applications together to send the transcription to the backend as a prompt and display the AI response on the frontend</p>
</li>
</ul>
<p>Optionally, you'll also learn how to host the frontend with Firebase and the backend with Google Cloud Run.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-web-speech-api">The Web Speech API</a></p>
<ul>
<li><a href="#heading-how-to-use-the-web-speech-api-in-javascript-for-seo">How to Use the Web Speech API in JavaScript for SEO</a></li>
</ul>
</li>
<li><p><a href="#heading-how-the-application-works">How the Application Works</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-application">How to Build the Application</a></p>
<ul>
<li><p><a href="#heading-create-the-backend-application-with-nodejs">Create the Backend Application with Node.js</a></p>
</li>
<li><p><a href="#heading-integrate-an-ai-assistant-into-the-nodejs-application">Integrate an AI Assistant into the Node.js Application</a></p>
</li>
<li><p><a href="#heading-create-the-frontend-application-with-vite">Create the Frontend Application with Vite</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-test-the-application-locally">Test the Application Locally</a></p>
</li>
<li><p><a href="#heading-deploy-the-backend-application-with-google-cloud-run">Deploy the Backend Application with Google Cloud Run</a></p>
</li>
<li><p><a href="#heading-deploy-the-frontend-application-with-firebase">Deploy the Frontend Application with Firebase</a></p>
</li>
<li><p><a href="#heading-connect-the-deployed-applications">Connect the Deployed Applications</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This guide assumes that you have a working knowledge of HTML, CSS, and JavaScript in the browser. Basic familiarity with Node.js is beneficial but not essential.</p>
<p>In addition, you should have:</p>
<ul>
<li><p>Google Chrome (at least version 33 ) and a functional audio input device</p>
</li>
<li><p><a href="https://nodejs.org/">Node.js</a>&nbsp;and npm installed on your computer</p>
</li>
<li><p>An API key from any AI assistant of your choice</p>
</li>
<li><p>A Google Cloud account and a Firebase account if you intend to deploy the applications</p>
</li>
</ul>
<h2 id="heading-the-web-speech-api">The Web Speech API</h2>
<p>The Web Speech API enables applications to transcribe the speech in audio input and also synthesise audio from text. The API is made up of two components:</p>
<ul>
<li><p>The <a href="https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition">SpeechRecognition</a> component which receives audio input, recognises speech in the input and transcribes it</p>
</li>
<li><p>The <a href="https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesis">SpeechSynthesis</a> component which synthesises speech from text</p>
</li>
</ul>
<p>You'll use the <code>SpeechRecognition</code> component in this guide.</p>
<h3 id="heading-how-to-use-the-web-speech-api-in-javascript-for-seo">How to Use the Web Speech API in JavaScript for SEO</h3>
<p>The <code>SpeechRecognition</code> component works through a JavaScript object instantiated in code.</p>
<pre><code class="language-javascript">const recognition = new SpeechRecognition();
</code></pre>
<p>The <code>recognition</code> instance exposes several event listeners that respond to audio input. For example, the <code>audiostart</code> event fires when sound is first detected, logging <code>"audio detected"</code> to the console as shown in the snippet below.</p>
<pre><code class="language-javascript">recognition.addEventListener("audiostart", function(event){
  console.log("audio detected")
}
</code></pre>
<p>The first time it recognises speech in a sound bite, the <code>speechstart</code> event is fired.</p>
<p>A <code>SpeechRecognition</code> instance also has the ability to configure how speech recognition should work. For example, it has a property called <code>lang</code> which sets the language that it should recognise. The default value of the <code>lang</code> property is the HTML <code>lang</code> attribute value, or the browser's language setting. It also has a boolean property called <code>interimResults</code>, which when set to true, enables the instance to return transcriptions incrementally rather than waiting for the audio input to end.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e28b713f978a0e2cd2b763/d3e7b39a-fcf2-4624-81ca-4667346c8269.png" alt="How speech is converted to transcripts via the Web Speech API" style="display: block;" width="1600" height="124" loading="lazy">

<p>Audio captured by the microphone is processed by a recognition engine which could be in a remote server (for Google Chrome) or embedded in the browser (for Firefox).</p>
<p>After processing, the recognition engine returns a result, which is a list of words or phrases that have been recognised in the speech.</p>
<p>Each transcription in the list has two properties: <code>confidence</code>, a numerical estimate of its accuracy ranging from 0 (low) to 1 (high), and <code>transcript</code>, the recognised text for all or part of the speech.</p>
<h2 id="heading-how-the-application-works">How the Application Works</h2>
<p>In order for a <code>SpeechRecognition</code> instance to capture audio, it needs access to the microphone. The browser requests permission to use the microphone and, if granted, the application uses it to capture audio for the instance.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e28b713f978a0e2cd2b763/e2847268-dfca-4f81-b929-7cc8ebd57eee.png" alt="Architecture diagram showing how Web Speech API sends transcription to a Node.js backend" style="display: block;" width="1279" height="514" loading="lazy">

<p>Speech captured by the instance goes through the recognition engine and produces results or transcriptions. Results with high confidence are combined and sent to the backend via an API request.</p>
<p>The backend uses the transcript it receives to prompt an AI assistant. The response from the AI assistant is sent back to the frontend and displayed on the UI as shown in the screenshot below:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e28b713f978a0e2cd2b763/0cd3eb46-595b-4193-82a0-874e8b9f9652.png" alt="Sample image of the voice-powered application built in this guide" style="display: block;" width="914" height="476" loading="lazy">

<h2 id="heading-how-to-build-the-application">How to Build the Application</h2>
<p>First, you'll build a Node.js backend application that:</p>
<ul>
<li><p>Receives a text prompt from the frontend</p>
</li>
<li><p>Sends the prompt to an AI assistant and receives a response</p>
</li>
<li><p>Returns the response of the AI assistant to the frontend</p>
</li>
</ul>
<p>Next, you'll build the frontend to:</p>
<ul>
<li><p>Accept your speech prompt, transcribe it, and display the transcription</p>
</li>
<li><p>Send the transcription result to the backend</p>
</li>
<li><p>Receive, format and display the response from the backend</p>
</li>
</ul>
<p>Optionally, you'll deploy the frontend to Firebase and the backend to Google Cloud Run, connecting them so the application is publicly accessible.</p>
<h3 id="heading-create-the-backend-application-with-nodejs">Create the Backend Application with Node.js</h3>
<p>The backend application you'll build in this section will receive text prompt from clients and use it to prompt an AI assistant. After receiving a response from the AI assistant, it will send the response back to the client.</p>
<p>We'll use Gemini in this guide, but you can use any AI assistant of your choice.</p>
<ol>
<li><p>Create a folder for the backend app and give it a name, for example, "server".</p>
</li>
<li><p>In terminal, navigate to the project folder, run the <code>npm init</code> command, and answer the follow-up questions to generate a <code>package.json</code> file</p>
</li>
<li><p>In the root of the project, create a file named <code>index.js</code>.</p>
</li>
</ol>
<p>Your project folder should have a structure like this:</p>
<pre><code class="language-plaintext">├── index.js
├── package.json
</code></pre>
<p>The <code>package.json</code> file should have the following values for <code>main</code> , <code>type</code> and <code>scripts.start</code>:</p>
<pre><code class="language-json"> { 
    "main": "index.js", 
    "type": "module", 
    "scripts": { 
       "start": "node index.js" 
    }, 
}  
</code></pre>
<ol>
<li>Copy and paste the code below into the <code>index.js</code> file to set up the server:</li>
</ol>
<pre><code class="language-javascript">import http from "node:http";

async function parseRequestBody(req) { 
    return new Promise((resolve, reject) =&gt; { 
        let data = ""; 
        req.on("data", (chunk) =&gt; (data += chunk)); 
        req.on("end", () =&gt; resolve(JSON.parse(data))); 
        req.on("error", reject); 
    }); 
}

const server = http.createServer(async function (req, res) { 
    switch (req.method) { 
        case "POST":
          return res.end("POST request received");
        default:
          return res.end("non-POST request received");
    }
})

const port = Number(process.env.PORT) || 8000; 
server.listen(port, function () { 
    console.log("server running on port", port); 
});
</code></pre>
<p>In the code snippet above, the <code>http</code> module is imported from Node.js. The <code>parseRequestBody</code> function converts the request body stream of a HTTP request to a JavaScript object.</p>
<p>It responds with <code>POST request received</code> for POST requests and <code>non-POST request received</code> for all others. By default, it listens on port 8000 unless a <code>PORT</code> environment variable is defined.</p>
<p>Run <code>npm run start</code> to start the server. To confirm it is running, execute the following command in the terminal:</p>
<pre><code class="language-shell"># For Linux/Mac, use:
curl -X POST -H "Content-Type: application/json" -d '{"prompt":"hello"}' http://localhost:8000

# For Windows, use:
curl.exe -X POST -H "Content-Type: application/json" -d '{"prompt":"hello"}' http://localhost:8000
</code></pre>
<p>You'll get the <code>POST request received</code> response from the server.</p>
<h3 id="heading-integrate-an-ai-assistant-into-the-nodejs-application">Integrate an AI Assistant into the Node.js Application</h3>
<p>In this section, you'll integrate the AI assistant into the backend application, prompt it with data sent from the frontend, and return its response to the client. Again, we'll use Gemini for this here.</p>
<p>Visit the npm page for your chosen AI assistant to learn how to install and set it up. Here are the npm pages for the most popular AI assistants:</p>
<ul>
<li><p><a href="https://www.npmjs.com/package/@anthropic-ai/sdk">Anthropic AI</a></p>
</li>
<li><p><a href="https://www.npmjs.com/package/@google/genai">Google Gemini</a></p>
</li>
<li><p><a href="https://www.npmjs.com/package/openai">Open AI</a></p>
</li>
</ul>
<p>Update the <code>index.js</code> file to include the setup for the AI assistant using the snippet below:</p>
<pre><code class="language-javascript">import http from "node:http";
import { GoogleGenAI } from "@google/genai"; 

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });

async function parseRequestBody(req) { /* minimised code */ }

const server = http.createServer(async function (req, res) {
    res.setHeader("Access-Control-Allow-Origin", "*");

    switch (req.method) { 
        case "POST":
          const body = await parseRequestBody(req);
          const response = await ai.models.generateContent({
            model: "gemini-2.5-flash", // or whatever model you have
            contents: body.prompt,
         });

         return res.end(response.text);

        default:
          return res.end("non-POST request received");
    }
}
/* previous code minimised*/
</code></pre>
<p>The <code>GEMINI_API_KEY</code> is retrieved from the environment variables and passed as the <code>apiKey</code> to <code>GoogleGenAI</code>, which initialises the AI assistant.</p>
<p>The POST request body is parsed into a JavaScript object, and <code>body.prompt</code> is passed to <code>ai.models.generateContent</code> to prompt the AI assistant. The <code>text</code> property of the response which is in Markdown format, is then returned to the client.</p>
<p>Restart the server and test the current setup by making an API request to it with curl using the snippet below:</p>
<pre><code class="language-shell"># For Linux/Mac:

curl -X POST -H "Content-Type: application/json" -d '{"prompt":"hello"}' http://localhost:8000

# For Windows:

curl.exe -X POST -H "Content-Type: application/json" -d '{"prompt":"hello"}' http://localhost:8000
</code></pre>
<p>You'll get an AI text response in the form of Markdown.</p>
<h3 id="heading-create-the-frontend-application-with-vite">Create the Frontend Application with Vite</h3>
<p><a href="https://vite.dev/">Vite</a> is a build tool that provides a faster and more seamless development experience for developing applications. You'll use Vite to create the frontend application and connect it with the backend application from the previous section.</p>
<p>In another folder, create a project with Vite by running the <code>npm create vite@latest</code> command and answer the prompts:</p>
<pre><code class="language-shell">npm create vite@latest

Need to install the following packages:
create-vite@8.1.0
Ok to proceed? (y) y

&gt; npx create-vite

◇  Project name:
│  [name-of-your-frontend-app] e.g prompt-ai-with-speech-frontend
│
◇  Select a framework:
│  Vanilla
│
◇  Select a variant:
│  JavaScript
│
◇  Use rolldown-vite (Experimental)?:
│  No
│
◇  Install with npm and start now?
│  Yes
</code></pre>
<p>Open the project created in your code editor and make the following updates:</p>
<p>First, replace the content of <code>index.html</code> with the code snippet below:</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
  &lt;head&gt;
    &lt;meta charset="UTF-8" /&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1.0" /&gt;
    &lt;title&gt;Prompt AI with the Web Speech Recognition API&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;main id="app"&gt;
      &lt;section&gt;
        &lt;h1&gt;Prompt AI with the Web Speech Recognition API&lt;/h1&gt;
        &lt;ul id="ulist_chat"&gt;&lt;/ul&gt;
      &lt;/section&gt;
      &lt;div class="btn_container"&gt;
        &lt;button id="btn_record"&gt;Record prompt&lt;/button&gt;
      &lt;/div&gt;
    &lt;/main&gt;
    &lt;script type="module" src="/src/main.js"&gt;&lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>Then replace the content of <code>src/style.css</code> with the code snippet below:</p>
<pre><code class="language-css">:root {
  font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;

  color-scheme: light dark;
  color: rgba(255, 255, 255, 0.87);
  background-color: #242424;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

button {
  border-radius: 8px;
  border: 1px solid transparent;
  padding: 0.6em 1.2em;
  font-size: 1em;
  font-weight: 500;
  font-family: inherit;
  background-color: #1a1a1a;
  cursor: pointer;
  transition: border-color 0.25s;
}
button:hover {
  border-color: #646cff;
}
button:focus,
button:focus-visible {
  outline: 4px auto -webkit-focus-ring-color;
}
.btn_container {
  padding: 16px 0px;
  display: flex;
  justify-content: center;
}

#ulist_chat {
  display: flex;
  flex-direction: column;
  width: 80%;
  margin: auto;
  padding: 0;
}

#ulist_chat .transcript {
  border: 1px solid tomato;
  background: #fce5e5af;
  border-radius: 4px;
  align-self: flex-end;
  list-style-type: none;
  margin: 8px;
  padding: 8px;
  max-width: 80%;
}

#ulist_chat .ai_response p { 
  margin: 2px; 
}

#ulist_chat .ai_response {
  border: 1px solid green;
  background: #e5fce8af;
  border-radius: 4px;
  align-self: flex-start;
  list-style-type: none;
  margin: 8px;
  padding: 8px;
  max-width: 80%;
}

@media (prefers-color-scheme: light) {
  :root {
    color: #000;
    background-color: #ffffff;
  }
  a:hover {
    color: #747bff;
  }
  button {
    background-color: #f9f9f9;
  }
}
</code></pre>
<p>Now replace the content of <code>src/main.js</code> with the code snippet below:</p>
<pre><code class="language-javascript">import "./style.css";
import { marked } from "marked";

const apiUrl = "http://localhost:8000";
const btnRecord = document.getElementById("btn_record");
const uListChat = document.getElementById("ulist_chat");

function ensureBrowserHasSpeechAPI() {
  if (
    !("webkitSpeechRecognition" in window) &amp;&amp;
    !("SpeechRecognition" in window)
  ) {
    btnRecord.style.display = "none";

    return alert(
      "This browser does not have the features required for this demo. Use Google Chrome &gt;= v33"
    );
  }

  start();
}

function toggleRecording(config, listener) {
  if (config.isListening) {
    config.isListening = false;
    btnRecord.innerText = "Start recording";
    return listener.stop();
  }

  config.isListening = true;
  btnRecord.innerText = "Stop recording";

  return listener.start();
}

/** @param {string} transcript  */
function appendTranscriptToChatList(transcript) {
  const li = document.createElement("li");
  li.innerText = transcript;
  li.classList.add("transcript");
  uListChat.appendChild(li);
}

/** @param {string} aiResponse  */
function appendAIResponseToChatList(aiResponse) {
  const li = document.createElement("li");
  li.innerHTML = marked.parse(aiResponse);
  li.classList.add("ai_response");
  uListChat.appendChild(li);
}

/** @param {string} prompt  */
async function promptAI(prompt) {
  try {
    const response = await fetch(apiUrl, {
      body: JSON.stringify({ prompt }),
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
    });

    if (!response.ok) {
      const err = await response.text();
      console.error(err);
      alert("An error occurred. Try again");
      return;
    }

    const text = await response.text();
    return text;
  } catch (error) {
    logError(error);
    alert("An error occurred. Try again");
    return ""
  }
}

function setUpSpeechRecognition() {
  const SpeechRecognition =
    window.SpeechRecognition || window.webkitSpeechRecognition;

  const listener = new SpeechRecognition();
  listener.continuous = true; // listen for long speech
  listener.maxAlternatives = 2; // only two transcription suggestions required
  let transcript = "";

  // automatic: onstart -&gt; onaudiostart -&gt; onsoundstart -&gt; onspeechstart
  // automatic: onspeechend -&gt; onsoundend -&gt; onaudioend -&gt; onresult -&gt; onend
  // click button: onaudioend -&gt; onresult -&gt; onend

  listener.onend = async function () {
    if (!transcript || !transcript.trim()) return;

    btnRecord.innerText = "Thinking...";
    btnRecord.disabled = true;
    appendTranscriptToChatList(transcript);
    promptAI(transcript)
      .then(function (res) {
        appendAIResponseToChatList(res);
      })
      .finally(function () {
        btnRecord.innerText = "Record prompt";
        btnRecord.disabled = false;
        transcript = "";
      });
  };

  listener.onerror = function (err) {
    logError(err);
    alert("Error occurred while capturing speech");
  };

  listener.onresult = function (event) {
    for (const alternatives of event.results) {
      const [bestAlternative] = Array.from(alternatives).toSorted(
        (altA, altB) =&gt; altB.confidence - altA.confidence
      );

      transcript += bestAlternative.transcript;
    }
  };

  return listener;
}

async function start() {
  const config = {
    isListening: false,
  };

  const listener = setUpSpeechRecognition();

  btnRecord.addEventListener("click", function () {
    toggleRecording(config, listener);
  });
}

ensureBrowserHasSpeechAPI();

function logError(...str) {
  for (const s of str) {
    console.error("error:", s);
  }
}
</code></pre>
<p><a href="https://www.npmjs.com/package/marked"><code>marked</code></a> is an npm package that helps convert Markdown text to HTML and it's a required dependency in the project. Install <code>marked</code> in the project by running the following command in the project's terminal:</p>
<pre><code class="language-shell">npm install marked
</code></pre>
<p>The <code>ensureBrowserHasSpeechAPI</code> function in <code>src/main.js</code> checks to see if the browser in use has the <code>WebSpeechAPI</code> feature. If it doesn't, it prevents the application from displaying the controls for the UI. That's why you'll need a Google Chrome browser with a version greater than or equal to 33 for this guide. Those versions have the <code>WebSpeechAPI</code> feature.</p>
<p>The <code>toggleRecording</code> function executes when the <strong>Record prompt</strong> button is clicked. On the first click, it requests microphone permission. It also enables/disables the activity of the <code>SpeechRecognition</code> instance.</p>
<p>The <code>setUpSpeechRecognition</code> function sets up the <code>SpeechRecognition</code> instance: <code>listener</code>, and its configuration. It also attaches functions to be run when the <code>end</code>, <code>error</code> and <code>result</code> events are triggered.</p>
<ul>
<li><p><code>error</code> is triggered when there is an error in capturing or processing audio</p>
</li>
<li><p><code>result</code> is triggered when the recognition engine returns transcription results</p>
</li>
<li><p><code>end</code> is triggered when the speech recognition service has disconnected from the application.</p>
</li>
</ul>
<p>The transcript is displayed on the UI after passing it as an argument to the <code>appendTranscriptToChatList</code> function.</p>
<p>The <code>promptAI</code> function executes when the <code>end</code> event fires, accepting the speech transcript as an argument and sending it to the backend via a POST request using <code>fetch</code>. On success, the AI response is returned as Markdown and passed to <code>appendAIResponseToChatList</code>, which converts it to HTML and displays it on the UI.</p>
<h2 id="heading-test-the-application-locally">Test the Application Locally</h2>
<p>Start the backend application by running <code>npm run start</code> in the backend project's terminal and start the frontend application by running <code>npm run dev</code> in the frontend project's terminal. Visit <code>http://localhost:5173</code> to view the UI of application. You should see a UI similar to the one in the image below:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e28b713f978a0e2cd2b763/c5d6fc8b-d693-4b83-9611-8e0c493c9f7c.png" alt="Initial image of the voice-powered AI chat app UI built with the Web Speech API" style="display: block;" width="1068" height="273" loading="lazy">

<p>Click the <strong>Record prompt</strong> button. A prompt will appear requesting microphone permission. Select "Allow while visiting the site" or "Allow this time" to grant access and begin recording. Click on the <strong>Stop recording</strong> button when you're done.</p>
<p>The UI will display the transcript of your speech and the application will send it to the backend as a prompt. After waiting for a short while, you'll see the response from the AI assistant displayed on the UI.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e28b713f978a0e2cd2b763/7a4ff532-6bd0-478c-ae3d-f6446c9d0a1f.png" alt="Final image of the voice-powered AI chat app UI built with the Web Speech API" style="display: block;" width="914" height="476" loading="lazy">

<p>You have been able to use speech input to prompt an AI assistant, receive a response and display it. How do you make this application accessible to everyone? The next section guides you through deploying both applications.</p>
<h2 id="heading-deploy-the-backend-application-with-google-cloud-run">Deploy the Backend Application with Google Cloud Run</h2>
<p>In this section, you'll deploy the backend application with Google Cloud Run and get a URL which will be used as the <code>apiUrl</code> in the frontend application.</p>
<p>In order to host the backend application with Google Cloud Run, you need to have a:</p>
<ul>
<li><p>Google Cloud developer account</p>
</li>
<li><p>Google Cloud project</p>
</li>
</ul>
<p>Visit <a href="https://cloud.google.com/">Google Cloud</a> to create an account and create a project. You can name the project whatever you want but it's a good idea to give a descriptive name. Take note of the project's ID because you'll use it in the deployment process.</p>
<p>There are three ways to deploy applications on Google Cloud Run:</p>
<ul>
<li><p>Deploy a revision from an existing container image</p>
</li>
<li><p>Deploy from a repository such as GitHub or GitLab</p>
</li>
<li><p>Create a function using the inline editor</p>
</li>
</ul>
<p>You can see all three options if you visit the <a href="https://console.cloud.google.com/run/create">create Cloud Run service</a> page.</p>
<p>In this guide, you'll use the option to deploy from an existing container image. Follow the steps below to deploy the backend server from a container image or follow the Cloud Run documentation at <a href="https://docs.cloud.google.com/run/docs/quickstarts/build-and-deploy/deploy-nodejs-service">build and deploy Node.js service on Cloud Run</a>:</p>
<ul>
<li><p>Install the Google Cloud (gcloud) CLI on your computer by visiting the <a href="https://docs.cloud.google.com/sdk/docs/install">Install Google Cloud CLI</a> page and following the instructions on the page for your operating system</p>
</li>
<li><p>Initialise the gcloud CLI to connect it to your developer account by visiting the <a href="https://docs.cloud.google.com/sdk/docs/initializing">Initializing the gcloud CLI</a> page and following the instructions on the page</p>
</li>
<li><p>Set the project you want to deploy the backend server under by running the command below in your terminal:</p>
</li>
</ul>
<pre><code class="language-shell"># replace PROJECT_ID with your project ID

gcloud config set project PROJECT_ID
</code></pre>
<ul>
<li><p>Visit your project's <a href="https://console.cloud.google.com/iam-admin/iam">IAM Admin</a> page to enable the following roles on the service account created for this project:</p>
<ul>
<li><p><code>roles/run.sourceDeveloper</code></p>
</li>
<li><p><code>roles/iam.serviceAccountUser</code></p>
</li>
<li><p><code>roles/logging.viewer</code></p>
</li>
</ul>
</li>
</ul>
<p>These roles are required to enable the Cloud Run Admin API and Cloud Build APIs. Take note of the service account email address.</p>
<ul>
<li>Enable the Cloud Run Admin API and Cloud Build APIs by running the code snippet below in your terminal:</li>
</ul>
<pre><code class="language-shell">gcloud services enable run.googleapis.com cloudbuild.googleapis.com
</code></pre>
<ul>
<li>Grant the Cloud Build service account access to your project by running the code snippet below in your terminal:</li>
</ul>
<pre><code class="language-plaintext"># replace PROJECT_ID with your project ID and 
# SERVICE_ACCOUNT_EMAIL_ADDRESS with the service account's email address

gcloud projects add-iam-policy-binding PROJECT_ID \
--member=serviceAccount:SERVICE_ACCOUNT_EMAIL_ADDRESS \
--role=roles/run.builder
</code></pre>
<p>Update <code>index.js</code> in the backend project to restrict API requests to clients specified in the <code>ALLOWED_ORIGINS</code> environment variable, and update the AI assistant configuration to use the API key loaded from environment variables.</p>
<pre><code class="language-javascript">// Use the API key from the environment variable
const GEMINI_API_KEY = process.env.GEMINI_API_KEY; 
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });

// Replace res.setHeader("Access-Control-Allow-Origin", "*"); with
res.setHeader("Access-Control-Allow-Origin", process.env.ALLOWED_ORIGINS);
res.setHeader("Access-Control-Allow-Methods", "POST,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
</code></pre>
<p>This ensures that the application will receive POST requests from only frontend URLs specified in the <code>ALLOWED_ORIGINS</code> environment variable. This setup prevents the backend from being loaded with requests from frontend clients that you don't know, and also prevents the excess use of your tokens. It also keeps you from deploying the application with the AI API key hardcoded in it.</p>
<p>To test that the new changes work, run the backend application with the command below:</p>
<pre><code class="language-shell"># replace YOUR_API_KEY with your Gemini API key

GEMINI_API_KEY=YOUR_API_KEY ALLOWED_ORIGINS="http://localhost:5173" npm run start
</code></pre>
<p>With the command in the code snippet above, the backend application will not respond to requests from frontend applications not hosted on <code>http://localhost:5173</code>. Try to send a prompt from the frontend application to test that it works.</p>
<p>To deploy the backend application to Cloud Run, run the command in the snippet below in the terminal of the backend project folder. The command sets the environment variables required for the application to run and also deploys it to Google Cloud Run.</p>
<pre><code class="language-plaintext"># replace &lt;api-key&gt; with your Gemini API key

gcloud run deploy --source . \
--set-env-vars "ALLOWED_ORIGINS=http://localhost:5173" \
--set-env-vars "GEMINI_API_KEY=&lt;api-key&gt;"
</code></pre>
<p>Once deployment is complete, you'll receive the URL of your hosted backend. Copy it and replace the value of <code>apiUrl</code> in your frontend application with it. Run the frontend, record a prompt, and confirm that everything works as expected.</p>
<h2 id="heading-deploy-the-frontend-application-with-firebase">Deploy the Frontend Application with Firebase</h2>
<p>In this section, you'll host the frontend application with Firebase. You need to have a Firebase account. Follow the steps below to host the frontend with Firebase:</p>
<ul>
<li><p>Create and set up a Firebase project</p>
</li>
<li><p>Install the Firebase CLI by visiting the <a href="https://firebase.google.com/docs/cli#install_the_firebase_cli">install Firebase CLI</a> page and follow the instructions for your operating system</p>
</li>
<li><p>In the terminal of the frontend project, run <code>firebase init hosting</code> to initialise the hosting configuration for the project. Follow the prompts and use <code>dist</code> as the public directory when prompted</p>
</li>
<li><p>Run <code>firebase deploy --only hosting</code> to host the application with Firebase</p>
</li>
</ul>
<p>Once deployment is complete, you will receive the URL of your hosted frontend application.</p>
<h2 id="heading-connect-the-deployed-applications">Connect the Deployed Applications</h2>
<p>Remember that the first time you deployed your backend application, you set <code>ALLOWED_ORIGINS</code> to <code>http://localhost:5173</code>. The deployed backend application doesn't know about the URL of the deployed frontend application so it won't accept requests from it.</p>
<p>In the terminal of the backend application, deploy the backend application again using the command in the snippet below:</p>
<pre><code class="language-shell"># replace &lt;frontend-url&gt; with your Firebase frontend URL and &lt;api-key&gt; 
# with your Gemini API key

gcloud run deploy --source . \
--set-env-vars "ALLOWED_ORIGINS=&lt;frontend-url&gt;" --set-env-vars "GEMINI_API_KEY=&lt;api-key&gt;"
</code></pre>
<p>Visit the deployed frontend application and test it. It should work without errors.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this guide, you built a frontend application that captures and transcribes speech, a Node.js backend application that prompts AI, and you connected both applications together to build a simplified version of the <strong>Use Voice</strong> feature in AI chat applications.</p>
<p>Can you add a feature to the application that will make it read out the response from the backend when it receives it? You can use the <a href="https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesis"><code>SpeechSynthesis</code> API</a> to build it.</p>
<p>Feel free to <a href="https://www.linkedin.com/in/orimdominicadah/">connect with me on LinkedIn</a> if you have any questions. Thank you for reading this far and don’t hesitate to share this article if you found it insightful. Cheers!</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
