Creating JWTs
We know what a JWT looks like. Three parts separated by dots: a header, a payload, and a signature. In the last lesson we took one apart. Now we are going to build one. By the end of this lesson, you will have a POST /token/login route that verifies a password and hands back a signed JWT. It is going to look weirdly similar to our session-based login, which is kind of the point: same flow, different delivery mechanism.
Install jose
npm install jose jose is a library for working with JWTs and related JOSE (JSON Object Signing and Encryption) standards. It uses the Web Crypto API under the hood, works in Node.js, Deno, Bun, and browsers, and has zero external dependencies. It is maintained by someone who has been working on this exact space for a long time and knows what they are doing.
Why jose instead of the old classic jsonwebtoken package? A few reasons: jose supports more modern key types, uses native Web Crypto (which is fast and standards-based), and has much better TypeScript support. The API takes a minute to get used to but is genuinely nicer once you see it.
The secret key
JWTs signed with HS256 (HMAC-SHA-256) require a secret that is at least 256 bits long, which is 32 bytes. Anything shorter and jose will actually reject it as insecure. That is not arbitrary: a secret shorter than the output of the hash is weaker than the algorithm, so it can be brute-forced more easily.
Create 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!",
); Let’s decode this. new TextEncoder().encode(...) converts a string into a Uint8Array (an array of bytes), which is what jose expects. process.env.JWT_SECRET is how Node reads environment variables. If the variable is set, we use it. If not, we fall back to a hardcoded development string. The fallback is only for your local dev convenience. In production, always use the environment variable.
Also notice that hardcoded string is exactly 48 characters, which is well over 32 bytes. If you shorten it while experimenting, jose will yell at you.
[!WARNING] In production, generate a proper secret like this:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))". Put the output in an environment variable. Never commit secrets to source code or to version control. If a secret leaks, rotate it immediately, because every token out there signed with the old secret becomes forge-able.
Signing a token
Now let’s actually create a token. Add this 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;
} The jose API uses a builder pattern. You start with new SignJWT(payload), chain a bunch of configuration methods, and finish with .sign(secret) to produce the final string. Let’s walk through each step:
new SignJWT(payload)creates a JWT builder with your custom claims as the payload. These are the fields you want inside the token: user ID, email, role..setProtectedHeader({ alg: "HS256" })sets the header. Thealgfield tells the verifier which algorithm was used. If someone tries to verify with a different algorithm, it fails..setIssuedAt()adds theiat(issued at) claim with the current timestamp. This records when the token was created. It is optional but good practice..setExpirationTime("24h")adds theexpclaim 24 hours from now. After 24 hours, the token is no longer valid.joseis nice and accepts human-readable durations like"15m","1h","7d"..sign(secret)does the actual cryptographic work. It computes the HMAC-SHA-256 signature using your secret and returns the complete JWT string.
The function is async because the crypto call is async (under the hood it uses the Web Crypto API, which returns promises).
The result looks like this:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiI0MiIsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20iLCJyb2xlIjoidXNlciIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDg2NDAwfQ.signature Three parts, two dots. Header, payload, signature. Exactly what we saw in the last lesson, except now we made one.
Adding a token login route
We are not going to replace our session-based login. We are going to add a separate route that does the same thing but returns a token instead of setting a cookie. That way you can see both approaches running side by side in the same app.
Create 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";
export const tokenAuthRoutes = group([
route.post("/token/login", {
request: { body: LoginBody },
resolve: async (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const { email, password } = c.input.body;
const user = users.get(email);
if (!user) {
return Response.json({ error: "Invalid email or password" }, { status: 401 });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return Response.json({ error: "Invalid email or password" }, { status: 401 });
}
const token = await createToken({
userId: user.id,
email: user.email,
role: user.role,
});
return Response.json({ token });
},
}),
]); Take a look and compare this to the session-based login from Section 3. It is nearly identical. The validation check is the same. The enumeration-attack-safe “Invalid email or password” message is the same. The bcrypt compare is the same.
The only difference is the last few lines. Instead of calling createSession and setting a cookie, we call createToken and return the token in the JSON response body. No cookies. No Set-Cookie header. Nothing about sessions.
And here is the really mind-bending part: the server stores nothing after this request completes. There is no session record. There is no database write. The token itself carries all the information the server will need on future requests, so after issuing it, the server is done. That is the whole point of stateless auth.
The client receives the token, and it is now fully responsible for storing it and sending it with future requests.
Wire it into the app
Update src/app.ts:
// src/app.ts
import { setup, route } from "@hectoday/http";
import { authRoutes } from "./routes/auth.js";
import { userRoutes } from "./routes/users.js";
import { tokenAuthRoutes } from "./routes/token-auth.js";
export const app = setup({
routes: [
route.get("/health", {
resolve: () => Response.json({ status: "ok" }),
}),
...authRoutes,
...userRoutes,
...tokenAuthRoutes,
],
}); Try it out
Sign up using the existing session-based signup (we are not changing that part), then hit the new token login route:
curl -X POST http://localhost:3000/signup \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "password123"}'
curl -X POST http://localhost:3000/token/login \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "password123"}' You should get back something like:
{
"token": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiI0Mi..."
} That is a real JWT. Paste it into jwt.io and you can see the decoded contents:
{
"userId": "42",
"email": "[email protected]",
"role": "user",
"iat": 1700000000,
"exp": 1700086400
} The iat and exp fields got added automatically by our setIssuedAt() and setExpirationTime() calls. You can see all the claims in plaintext, which is the point we hammered on in the last lesson: JWTs are signed, not encrypted. Anyone with the token can read what is inside it. The signature just stops them from changing it.
But can I do anything with this token yet?
Good question. Not yet. The token exists but we have no route that actually accepts it. You could send this token back as an Authorization: Bearer <token> header to any endpoint, and the server would currently ignore it entirely because nothing reads that header.
That is exactly what the next lesson solves. We will write a verifyToken function, then a token-based authenticate function, then a route that uses them. By the end of the next lesson, the full token-based auth flow will be working end to end.
Exercises
Exercise 1: Get a token from POST /token/login. Then decode the payload yourself. Split the token string by . to get three parts. Take the second part (the payload) and decode it using the decodeBase64Url function from the previous lesson. Verify you can see your user data and the iat/exp timestamps.
Exercise 2: Change the expiration time in createToken from "24h" to "5s" (5 seconds). Get a token, immediately use it in the next lesson’s token-protected routes (should work), then wait 6 seconds and try again (should fail). Change it back to "24h" when done. This demonstrates how exp controls token lifetime.
Exercise 3: Create a second token login route POST /token/login-short that issues tokens with a 15-minute expiry. Same logic, different setExpirationTime value. This demonstrates how the same createToken approach can be adapted for different use cases: short-lived API tokens vs. longer session-like tokens.
Why does jose require the secret to be at least 256 bits (32 bytes) for HS256?
After calling POST /token/login, where is the user's identity stored?