hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Authentication with @hectoday/http

What Is Authentication?

  • Who Are You?
  • HTTP Is Stateless
  • Project Setup

Passwords

  • Why Not Store Passwords Directly
  • Hashing with bcrypt
  • Building a Signup Route
  • Building a Login Route

Sessions and Cookies

  • What Is a Cookie?
  • What Is a Session?
  • Building Session Management
  • Protecting Routes
  • Logout
  • Cookie Security

Tokens

  • What Is a Token?
  • Anatomy of a JWT
  • Creating JWTs
  • Verifying JWTs
  • Sessions vs. Tokens

Putting It Together

  • Authorization
  • Common Mistakes
  • Capstone: User Management API

Verifying JWTs

Last lesson we wrote the route that hands out JWTs. The user logs in, we check the password, and we give them back a signed token. But right now, that token is useless. If the user sends it back to us on the next request, nothing reads it. This lesson fixes that. We are going to write a verifyToken function, use it to build a token-based authenticate, and create a protected route that only accepts requests with a valid JWT. By the end of this lesson, the full token-based auth flow will work end to end.

The verification function

Add a verifyToken function to src/jwt.ts:

// src/jwt.ts
import { SignJWT, jwtVerify } from "jose";

const secret = new TextEncoder().encode(
  process.env.JWT_SECRET ?? "development-secret-change-in-production-32chars!",
);

export async function createToken(payload: {
  userId: string;
  email: string;
  role: string;
}): Promise<string> {
  const token = await new SignJWT(payload)
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("24h")
    .sign(secret);

  return token;
}

export async function verifyToken(
  token: string,
): Promise<{ userId: string; email: string; role: "admin" | "user" } | null> {
  try {
    const { payload } = await jwtVerify(token, secret);

    if (
      typeof payload.userId !== "string" ||
      typeof payload.email !== "string" ||
      (payload.role !== "admin" && payload.role !== "user")
    ) {
      return null;
    }

    return {
      userId: payload.userId,
      email: payload.email,
      role: payload.role,
    };
  } catch {
    return null;
  }
}

Two things happening here: the jose verification call, and some manual type checks after.

First the jose part. jwtVerify(token, secret) does a bunch of work all at once:

  1. Decodes the header and payload from Base64URL.
  2. Recomputes the signature using the header, payload, and the secret.
  3. Compares the recomputed signature to the signature that came in the token.
  4. Checks the exp claim against the current time (rejects expired tokens).
  5. Checks the nbf (not before) claim if present.

If any of these steps fail, jwtVerify throws an error. Our try/catch wraps everything so we can turn any failure into a clean return null.

Now the second part, the manual type checks. After jwtVerify succeeds, we still check the shape of the payload ourselves. This might feel paranoid, but it matters. The JWT spec allows the payload to be literally any JSON. A validly signed token with the wrong shape (missing fields, wrong types) would technically pass jwtVerify but then blow up our downstream code when we try to use payload.userId.toString() or whatever. The manual check keeps us honest and gives the TypeScript compiler the proof it needs to narrow the types.

If anything looks off, we return null. If everything checks out, we return a clean, typed object.

The Authorization header

Quick refresher on how tokens travel. They are in the Authorization header, with the Bearer scheme:

Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

The exact format is the word Bearer, a single space, and then the token. We need to read the header off the request, chop off the Bearer prefix, and hand the rest to verifyToken.

Token-based authenticate

Now we build an equivalent of our session-based authenticate function, but for tokens. Open src/auth.ts and add a new function below the existing one:

// src/auth.ts
import type { User } from "./db.js";
import { getSessionId } from "./cookies.js";
import { getSession } from "./sessions.js";
import { verifyToken } from "./jwt.js";

// Session-based auth (existing)
export function authenticate(request: Request): Omit<User, "passwordHash"> | Response {
  const sessionId = getSessionId(request);
  if (!sessionId) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const session = getSession(sessionId);
  if (!session) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  return {
    id: session.userId,
    email: session.email,
    role: session.role,
  };
}

// Token-based auth (new)
export async function authenticateToken(
  request: Request,
): Promise<Omit<User, "passwordHash"> | Response> {
  const header = request.headers.get("authorization");
  if (!header?.startsWith("Bearer ")) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const token = header.slice(7); // Remove "Bearer " prefix
  const payload = await verifyToken(token);
  if (!payload) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  return {
    id: payload.userId,
    email: payload.email,
    role: payload.role,
  };
}

Let’s go through the new one line by line.

request.headers.get("authorization") reads the Authorization header. If it is missing, this returns null.

The header?.startsWith("Bearer ") check handles two failure modes in one: if the header is missing (null) or if it does not start with the Bearer prefix, we reject with a 401. The ?. is optional chaining: if header is null, the whole expression is undefined, which is falsy, so the ! flips it to true and we return the error. Nice and compact.

header.slice(7) chops off the first 7 characters, which is exactly "Bearer " including the trailing space. What is left is the raw token.

We pass the token to verifyToken. If it returns null (bad signature, expired, wrong shape, whatever), we return a 401. Otherwise we return the user data in the same shape as session-based authenticate.

Notice how similar the overall pattern is to session-based auth. Same return type (user or Response). Same short-circuit pattern. The only real difference is that this version is async because jwtVerify is async under the hood.

A token-protected route

Now the fun part. Let’s add a token-protected /token/me route so we can actually exercise this. Update src/routes/token-auth.ts:

// src/routes/token-auth.ts
import bcrypt from "bcryptjs";
import { route, group } from "@hectoday/http";
import { users } from "../db.js";
import { LoginBody } from "../schemas.js";
import { createToken } from "../jwt.js";
import { authenticateToken } from "../auth.js";

export const tokenAuthRoutes = group([
  route.post("/token/login", {
    request: { body: LoginBody },
    resolve: async (c) => {
      // ... same as before
    },
  }),

  route.get("/token/me", {
    resolve: async (c) => {
      const caller = await authenticateToken(c.request);
      if (caller instanceof Response) return caller;

      return Response.json({ user: caller });
    },
  }),
]);

Look at the pattern. Same two lines as the session-based /me from Section 3, just with an await:

const caller = await authenticateToken(c.request);
if (caller instanceof Response) return caller;

The only difference from the session version is that word await. Everything else about the handler is identical. That is the beauty of returning the same shape (User | Response) from both functions. The handler does not have to care which flavor of auth was used.

Try it out

Run through the whole flow:

# Sign up
curl -X POST http://localhost:3000/signup \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "password123"}'

# Get a token
TOKEN=$(curl -s -X POST http://localhost:3000/token/login \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "password123"}' \
  | grep -o '"token":"[^"]*"' | cut -d'"' -f4)

echo $TOKEN

# Use the token
curl http://localhost:3000/token/me \
  -H "Authorization: Bearer $TOKEN"
{
  "user": {
    "id": "a1b2c3d4-...",
    "email": "[email protected]",
    "role": "user"
  }
}

It works. The token carries the identity, the server verifies it, and we get the user back.

Now try with a tampered token (just mash some extra characters into the middle of the token string):

curl http://localhost:3000/token/me \
  -H "Authorization: Bearer invalidtoken"
{ "error": "Unauthorized" }

What about no header at all?

curl http://localhost:3000/token/me
{ "error": "Unauthorized" }

All three cases are handled correctly: valid token works, tampered token rejected, missing header rejected.

Wait, where is the logout?

You might be wondering why there is no /token/logout route. Here is the thing: with tokens, there is nothing to delete on the server. The server never stored anything in the first place. The token was self-contained.

So “logging out” with a token is a bit of a philosophical problem. The client can throw away its copy of the token, which prevents it from using it again. But the token itself is still technically valid until it expires. If a copy of the token leaked somewhere (say, an attacker copied it), throwing away your own copy does not stop them.

If you absolutely need to invalidate tokens before they expire, you have a few imperfect options:

  • Keep a deny list of revoked token IDs on the server. Every request checks against this list. This works, but it reintroduces server-side state, which was the main reason you picked tokens in the first place.
  • Use very short token lifetimes plus refresh tokens. Access tokens expire in 15 minutes. Long-lived refresh tokens (which can be revoked) are used to request new access tokens. The attacker’s stolen token lasts at most 15 minutes. This is the industry standard pattern but it is significantly more complex.
  • Change the signing secret. Instantly invalidates every token for every user. Useful in an extreme emergency, useless as a normal logout.

None of these are as clean as the session-based logout we built in Section 3, where deleting a record kills the session immediately. “Stateless” sounds beautiful, but it has this cost, and you should understand it before you choose tokens for a project.

Exercises

Exercise 1: Get a token from /token/login. Then tamper with it: change one character somewhere in the middle of the token string. Try using the tampered token with /token/me. You should get 401 because the signature no longer matches the payload.

Exercise 2: Try sending the token in the wrong format. What happens if you send Authorization: eyJhbG... (without the Bearer prefix)? What about Authorization: bearer eyJhbG... (lowercase b)? Check that authenticateToken handles both cases correctly, or adjust it if you think the behavior should be different.

Exercise 3: Combine token-based auth with the admin check we will write in Section 5. Create a GET /token/users route that requires both a valid token and the admin role. Test it with a regular user token (should get 403) and an admin token (should work).

In the next lesson, we pause and reflect. You now have built both session-based and token-based authentication. So which one should you actually use? Spoiler: it depends. But the tradeoffs are fascinating, and understanding them will make you a much better architect.

Why is authenticateToken an async function while authenticate (session-based) is synchronous?

What does `header.slice(7)` do?

← Creating JWTs Sessions vs. Tokens →

© 2026 hectoday. All rights reserved.