JWT Best Practices in a Serverless Architecture

JWT Best Practices in a Serverless Architecture

When I started building Jottings, I quickly realized that authentication in serverless is fundamentally different from traditional server architectures. You can't rely on session state. You can't trust that your server will be running between requests. Every function invocation is ephemeral, stateless, and independent.

This is where JWTs become indispensable.

But implementing JWT authentication correctly in a serverless environment requires understanding some nuances that don't matter in traditional applications. I've learned these lessons the hard way, and I want to share what's worked for us at Jottings.

Why JWTs Are Perfect for Serverless

Traditional server-based applications often use session tokens stored in a database or cache. When a user makes a request, the server looks up the session. This pattern breaks in serverless because:

  1. No shared state between invocations - Your Lambda function can't store session data locally
  2. Scaling is unpredictable - You might have ten functions running simultaneously or zero running
  3. Keep-alive connections don't exist - Each request starts fresh

JWTs solve these problems elegantly. They're self-contained: the token itself contains all the information you need, cryptographically signed by a trusted authority. Your Lambda function doesn't need to query a database to validate a token—it just needs to verify the signature.

At Jottings, we use AWS Cognito to issue JWTs. Our Lambda functions validate tokens on every request, but we never need to make an external call to do it. The token is self-validating.

Our Token Validation Approach

Here's how it works in practice:

When a user logs in through our Cognito pool, they receive three tokens:

  • Access Token - Used to call our API
  • ID Token - Contains user identity claims
  • Refresh Token - Used to get new access tokens before expiry

Our frontend stores these tokens and includes the access token in the Authorization header for every API request.

On the backend, our auth middleware intercepts every request:

// Simplified version of our auth middleware
export async function validateToken(token: string) {
  try {
    const decoded = jwt.verify(token, publicKey);
    return { valid: true, claims: decoded };
  } catch (error) {
    return { valid: false, error: error.message };
  }
}

The key insight here is that we validate locally using the public key. We don't call Cognito to check if the token is valid. We just verify the signature using the public key (which Cognito publishes openly).

This is crucial for serverless performance. Every network call adds latency and cold starts. By validating locally, our token validation takes microseconds, not milliseconds.

Best Practices We Follow

1. Validate on Every Endpoint

This seems obvious, but it's easy to skip token validation "just for this one endpoint." Don't do that. We have a consistent auth middleware that wraps every protected endpoint. Zero exceptions.

app.get('/api/v1/sites', authenticateRequest, (req, res) => {
  // req.user is populated by authenticateRequest middleware
  const userId = req.user.sub;
  // ... rest of handler
});

2. Use Short-Lived Access Tokens

Our access tokens expire in 1 hour. This limits the window of exposure if a token is compromised. The user's refresh token can issue new access tokens without requiring them to log in again.

This balance is important: short enough to be secure, long enough that users aren't constantly re-authenticating.

3. Verify Signature, Check Expiration, Validate Claims

Token validation isn't just about the signature. We verify:

  • Signature - Is this token actually from Cognito?
  • Expiration - Is the token still valid in time?
  • Issuer claim - Is it from our Cognito pool?
  • Audience claim - Is it intended for our API?

Most JWT libraries handle this automatically when you verify with the public key, but it's worth being explicit about what you're checking.

4. Store Public Keys with Caching

Cognito publishes public keys at a standard endpoint (the JWKS endpoint). We fetch and cache these keys locally. Caching prevents unnecessary network calls while ensuring we pick up key rotations.

// Cache keys for 24 hours
const getCognitoPublicKey = () => {
  if (cache.isValid()) {
    return cache.getKey();
  }
  // Fetch from Cognito's JWKS endpoint if cache expired
  const key = await fetchFromCognito();
  cache.set(key, 24 * 60 * 60); // 24 hours
  return key;
};

5. Extract User Context Consistently

Once the token is validated, we extract the user's ID (the sub claim) and attach it to the request object. Every handler can rely on req.user.sub being present and valid.

This prevents scattered token-parsing logic across your codebase.

6. Handle Token Expiration Gracefully

When a token expires, we return a 401 Unauthorized response with a clear error code. The frontend knows to refresh the token and retry the request.

This is seamless to the user, but it requires frontend code to handle token refresh:

// Frontend refreshes expired token and retries
if (response.status === 401) {
  await amplifyAuth.signInWithToken(refreshToken);
  // Retry the original request
}

Common Mistakes We Avoided

Storing JWTs in LocalStorage

We initially considered this, but it's vulnerable to XSS attacks. Instead, we let AWS Amplify manage token storage in the browser using secure mechanisms (HttpOnly cookies when possible).

Validating Tokens Against the Database

This defeats the entire purpose of JWTs. If you're making a database call to validate every token, you've added latency and complexity without the benefits of JWT.

Trusting the Token Without Verification

Just decoding a JWT isn't enough. You must verify the signature. A decoded JWT without signature verification is meaningless.

Ignoring Token Expiration

Tokens have an exp claim for a reason. Validating this claim ensures that compromised tokens eventually become useless.

The Result: Scalable, Stateless Auth

Because our token validation is local, synchronous, and fast, we can authenticate thousands of concurrent requests without any bottleneck. Each Lambda function handles validation independently. There's no central auth service to hit, no database query to make.

This is the serverless advantage: authentication that scales with your traffic automatically.

For Jottings, this means our users can publish jots, upload photos, and build their sites without ever noticing the authentication layer. It's there, it's secure, and it gets out of the way.

Next Steps

If you're building a serverless application, I'd encourage you to:

  1. Choose a JWT provider (Cognito, Auth0, Firebase) that handles token issuance for you
  2. Implement consistent token validation middleware across all endpoints
  3. Cache public keys to avoid unnecessary network calls
  4. Use short-lived access tokens with longer-lived refresh tokens
  5. Validate claims beyond just the signature

Authentication doesn't have to be complex in serverless. When you align your architecture with how JWTs work, everything becomes simpler, faster, and more secure.

Building Jottings has taught me that the best security is the kind that's so integrated into your system that it feels invisible. Your users shouldn't worry about authentication—they should just worry about what they're creating.


If you're interested in how we built authentication into Jottings, check out the platform. It's free to get started, and you can create your own microblog in minutes.