JWT Authentication

Issuing and Verifying Tokens with koa-jwt

JWT Authentication

koa-jwt verifies Bearer tokens on protected routes and exposes the decoded payload on ctx.state.user. Pair it with jsonwebtoken to issue tokens on login.

5 min read Level 3/5 #koa#jwt#auth
What you'll learn
  • Issue signed JWTs on login using jsonwebtoken
  • Protect routes with koa-jwt middleware
  • Access ctx.state.user and selectively bypass protection with passthrough/unless

JWTs (JSON Web Tokens) are signed, not encrypted. Anyone who holds the token can decode its payload — never put passwords, full PII, or secrets inside. Sign with a long, random secret (JWT_SECRET) stored in an environment variable.

Installation

npm install koa-jwt jsonwebtoken

Issuing a Token on Login

import jwt from "jsonwebtoken";
import bcrypt from "bcryptjs";
import { findUserByEmail } from "./db.js";

router.post("/auth/login", async (ctx) => {
  const { email, password } = ctx.request.body;
  const user = await findUserByEmail(email);

  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    ctx.status = 401;
    ctx.body = { error: "Invalid credentials" };
    return;
  }

  // Payload: minimal, non-sensitive claims only.
  const token = jwt.sign(
    { sub: user.id, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: "2h", algorithm: "HS256" }
  );

  ctx.body = { token };
});

Protecting Routes with koa-jwt

Mount koa-jwt before any route that needs protection. It reads the Authorization: Bearer <token> header, verifies the signature, and places the decoded payload on ctx.state.user.

import Koa from "koa";
import koaJwt from "koa-jwt";

const app = new Koa();

// Protect everything below this middleware.
app.use(
  koaJwt({ secret: process.env.JWT_SECRET, algorithms: ["HS256"] })
);

// ctx.state.user is available here.
app.use(async (ctx) => {
  ctx.body = { userId: ctx.state.user.sub };
});

Bypassing Protection for Public Routes

Use unless to let public endpoints through without a token:

app.use(
  koaJwt({ secret: process.env.JWT_SECRET, algorithms: ["HS256"] }).unless({
    path: [/^\/auth/, /^\/public/],
  })
);

Or use passthrough: true to continue even without a token (the middleware will not set ctx.state.user), letting your route decide:

app.use(
  koaJwt({
    secret: process.env.JWT_SECRET,
    algorithms: ["HS256"],
    passthrough: true,
  })
);

app.use(async (ctx) => {
  if (!ctx.state.user) {
    // anonymous request
  }
});

Token Expiry and Revocation

JWTs are valid until exp. To invalidate a token early you need a server-side blocklist (e.g., a Redis set of revoked jti claims). Check the blocklist in a custom middleware immediately after koa-jwt:

app.use(async (ctx, next) => {
  const { jti } = ctx.state.user ?? {};
  if (jti && (await redis.sIsMember("revoked_tokens", jti))) {
    ctx.status = 401;
    ctx.body = { error: "Token revoked" };
    return;
  }
  await next();
});

Up Next

Add strategy-based auth (local, social) to your Koa app with koa-passport.

Passport Strategies →