DEV Community

Cover image for Stop using JWT libraries: How to build your own Lightweight Tokens with node:crypto
Insaf
Insaf

Posted on

Stop using JWT libraries: How to build your own Lightweight Tokens with node:crypto

Ever looked at your node_modules and wondered why you need a massive library just to sign a small JSON object?

If you are building a Node.js backend, you already have a powerhouse security tool built-in: the node:crypto module.
Today, we’re going to build a custom, secure authentication token system that is faster, leaner, and more secure than standard JWT libraries.

Why skip the library?

  1. Zero Dependencies: No jsonwebtoken means one less package to audit for vulnerabilities.

  2. Performance: node:crypto is a native C++ binding in Node; it’s blazingly fast.

  3. No "Algorithm Switching" Attacks: Most JWT bugs happen because the library allows the client to choose the algorithm (like alg: none). In our code, we hardcode the security.

The Logic

A secure token has two parts:

  1. The Payload: A Base64URL encoded string containing user data (ID, Username, Avatar).

  2. The Signature: An HMAC (Hash-based Message Authentication Code) that proves the payload hasn't been tampered with.


1. The Signing Function

This function takes your user data, adds an expiration timestamp, and signs it using a secret key from your .env.


import { createHmac } from "node:crypto";

const TOKEN_SECRET = process.env.TOKEN_SECRET;

export const signToken = (payload: object) => {
  // Add 15 minute expiration
  const claims = {
    ...payload,
    exp: Date.now() + 15 * 60 * 1000,
  };

  // 1. Encode data to a URL-safe string
  const encodedPayload = Buffer.from(JSON.stringify(claims)).toString("base64url");

  // 2. Create the signature (The "Security Seal")
  const signature = createHmac("sha256", TOKEN_SECRET!)
    .update(encodedPayload)
    .digest("base64url");

  // 3. Combine them with a dot
  return `${encodedPayload}.${signature}`;
};
Enter fullscreen mode Exit fullscreen mode

2. The Verification Function

When the client sends the token back, we need to ensure it's still valid. We use timingSafeEqual to prevent timing attacks, a subtle way hackers guess secrets by measuring how long a string comparison takes.


import { createHmac, timingSafeEqual } from "node:crypto";

export const verifyToken = (token: string) => {
  const [encodedPayload, signature] = token.split(".");
  if (!encodedPayload || !signature) throw new Error("Malformed token");

  // Re-calculate what the signature should be
  const expectedSignature = createHmac("sha256", process.env.TOKEN_SECRET!)
    .update(encodedPayload)
    .digest("base64url");

  // Use timingSafeEqual for high-level security
  const isValid = timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );

  if (!isValid) throw new Error("Invalid signature!");

  const claims = JSON.parse(Buffer.from(encodedPayload, "base64url").toString());

  // Check if expired
  if (Date.now() > claims.exp) throw new Error("Token expired");

  return claims;
};
Enter fullscreen mode Exit fullscreen mode

Pro-Tip: Watch your Header Size!

Since we are putting "heavy" data like avatars and usernames inside the token, the string can get long.

Shorten your keys: Use u for username and img for avatar.

Keep it lean: Only store data the UI needs immediately.

Conclusion

By using node:crypto, you gain total control over your authentication. You aren't just following a tutorial; you're understanding the math and logic that keeps the web secure.

Are you still using JWT libraries, or have you moved to native crypto? Let me know in the comments!

Top comments (0)