Hectoday
Docs
Web fundamentals with @hectoday/http

Authentication & Authorization from First Principles

A guide for developers who know they need auth but aren't sure how it actually works. Every example uses @hectoday/http, but the ideas apply to any server.

Two different questions

Authentication and authorization are different things. People confuse them constantly.

Authentication answers: "Who are you?" The client proves its identity. Usually with a token, a session cookie, or an API key.

Authorization answers: "Are you allowed to do this?" The server knows who you are and checks whether that identity has permission for the requested action.

Authentication comes first. You can't check permissions until you know who's asking.

Request arrives
  → Authentication: who is this?
    → Authorization: can they do this?
      → Handler: do the thing

How the client proves its identity

Three common approaches. Each sends a credential with every request.

Bearer tokens

The client sends a token in the Authorization header:

GET /users HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

The server validates the token and extracts the user's identity from it. The token is self-contained (it carries the user data) or is a reference (the server looks up the user data).

Most modern APIs use this approach.

Session cookies

The client sends a cookie that maps to a server-side session:

GET /users HTTP/1.1
Cookie: session_id=abc123

The server looks up abc123 in a session store (Redis, database) and gets the user data. The cookie is just an ID. The data lives on the server.

Traditional web apps use this. It requires CORS credentials: "include" for cross-origin requests.

API keys

The client sends a static key, usually in a header:

GET /users HTTP/1.1
X-API-Key: sk_live_abc123def456

The server looks up the key in a database and gets the associated account. API keys identify a client application, not a human user. They don't expire (unless revoked) and are typically used for server-to-server communication.

JWTs: what they are and what they aren't

A JWT (JSON Web Token) is a signed JSON object encoded as a string. It has three parts separated by dots:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEiLCJyb2xlIjoiYWRtaW4ifQ.SflKxwRJSM...

Header — which algorithm was used to sign it.

Payload — the actual data (called "claims"). User ID, role, expiration time.

Signature — proves the token wasn't tampered with.

The server creates the token by signing the payload with a secret key. Later, when the client sends the token back, the server verifies the signature. If it's valid, the payload is trustworthy.

// Creating a JWT (at login)
const token = await sign({ sub: user.id, role: user.role }, SECRET, { expiresIn: "1h" });

// Verifying a JWT (on every request)
const payload = await verify(token, SECRET);
// payload.sub is the user ID
// payload.role is the user's role

What JWTs are good for

  • Stateless auth: the server doesn't need to store sessions
  • Cross-service auth: any service with the secret can verify the token
  • Carrying claims: the user's role, permissions, org ID are in the token itself

What JWTs are not

  • Not encrypted. Anyone can decode the payload. Don't put secrets in it. The signature proves integrity, not confidentiality.
  • Not revocable. Once issued, a JWT is valid until it expires. You can't "log out" a JWT without a blocklist, which reintroduces server-side state.
  • Not a session. JWTs are credentials, not session storage. Don't store shopping carts or UI state in them.

The authenticate function

In Hectoday, auth is a plain function. No middleware, no decorators, no framework primitive. A function that takes a Request and returns either the authenticated user or an error Response:

interface User {
  id: string;
  name: string;
  email: string;
  role: "admin" | "user";
}

function authenticate(request: Request): User | Response {
  const header = request.headers.get("authorization");

  if (!header?.startsWith("Bearer ")) {
    return Response.json({ error: "Missing or invalid Authorization header" }, { status: 401 });
  }

  const token = header.slice(7);
  const user = verifyToken(token);

  if (!user) {
    return Response.json({ error: "Invalid or expired token" }, { status: 401 });
  }

  return user;
}

This function does one thing: extract and verify the credential. It either succeeds (returns a User) or fails (returns a 401 Response). The error response is decided right here, next to the check.

Using it in a handler

Call the function and check with instanceof:

route.get("/users", {
  resolve: async (c) => {
    const caller = authenticate(c.request);
    if (caller instanceof Response) return caller;
    // caller is User — TypeScript narrowed it

    const users = await db.users.list();
    return Response.json({ users });
  },
});

Two lines. The auth call and the check. If auth fails, the handler returns the error Response immediately. If it succeeds, caller is a fully typed User object for the rest of the handler.

Every return is visible. No hidden middleware. No wondering where execution went.

Authorization: checking permissions

Authentication tells you who. Authorization tells you whether they can.

function requireAdmin(user: User): true | Response {
  if (user.role !== "admin") {
    return Response.json({ error: "Admin access required" }, { status: 403 });
  }
  return true;
}

Use it after authentication:

route.delete("/users/:id", {
  request: { params: z.object({ id: z.string().uuid() }) },
  resolve: async (c) => {
    const caller = authenticate(c.request);
    if (caller instanceof Response) return caller;

    const admin = requireAdmin(caller);
    if (admin instanceof Response) return admin;

    if (!c.input.ok) {
      return Response.json({ error: c.input.issues }, { status: 400 });
    }

    await db.users.delete(c.input.params.id);
    return new Response(null, { status: 204 });
  },
});

Authentication returns 401 (who are you?). Authorization returns 403 (you can't do this). Different checks, different status codes, different meanings.

Resource-level authorization

Sometimes authorization depends on the specific resource, not just the user's role:

function requireOwner(user: User, resource: { ownerId: string }): true | Response {
  if (resource.ownerId !== user.id) {
    return Response.json({ error: "You don't own this resource" }, { status: 403 });
  }
  return true;
}
resolve: async (c) => {
  const caller = authenticate(c.request);
  if (caller instanceof Response) return caller;

  if (!c.input.ok) {
    return Response.json({ error: c.input.issues }, { status: 400 });
  }

  const post = await db.posts.get(c.input.params.id);
  if (!post) {
    return Response.json({ error: "Not found" }, { status: 404 });
  }

  const owner = requireOwner(caller, post);
  if (owner instanceof Response) return owner;

  // caller owns this post
  await db.posts.delete(post.id);
  return new Response(null, { status: 204 });
};

The check happens after fetching the resource because you need the resource to check ownership. The order matters: authenticate, validate, fetch, authorize, act.

Composing checks

Combine common patterns into reusable functions:

function authenticatedAdmin(request: Request): User | Response {
  const user = authenticate(request);
  if (user instanceof Response) return user;

  const admin = requireAdmin(user);
  if (admin instanceof Response) return admin;

  return user;
}

Now any handler that needs admin auth is two lines:

resolve: async (c) => {
  const caller = authenticatedAdmin(c.request);
  if (caller instanceof Response) return caller;

  // caller is User with admin role
};

Build whatever combinations your app needs:

function authenticatedWithOrg(
  request: Request,
  orgId: string,
): { user: User; org: Org } | Response {
  const user = authenticate(request);
  if (user instanceof Response) return user;

  const org = getOrgMembership(user.id, orgId);
  if (!org) {
    return Response.json({ error: "Not a member of this organization" }, { status: 403 });
  }

  return { user, org };
}
resolve: async (c) => {
  if (!c.input.ok) {
    return Response.json({ error: c.input.issues }, { status: 400 });
  }

  const auth = authenticatedWithOrg(c.request, c.input.params.orgId);
  if (auth instanceof Response) return auth;

  // auth.user and auth.org are both typed
};

These are just functions. No framework registration. No decorator syntax. Put them in an auth.ts file and import them.

Token verification in practice

The verifyToken function depends on your token strategy. Here are the common ones:

JWT with a shared secret

import { jwtVerify } from "jose";

const SECRET = new TextEncoder().encode(process.env.JWT_SECRET);

async function verifyToken(token: string): Promise<User | null> {
  try {
    const { payload } = await jwtVerify(token, SECRET);
    return {
      id: payload.sub as string,
      name: payload.name as string,
      email: payload.email as string,
      role: payload.role as "admin" | "user",
    };
  } catch {
    return null;
  }
}

Opaque token with database lookup

async function verifyToken(token: string): Promise<User | null> {
  const session = await db.sessions.findByToken(token);
  if (!session || session.expiresAt < new Date()) return null;

  return db.users.get(session.userId);
}

API key

async function verifyApiKey(request: Request): Promise<Account | Response> {
  const key = request.headers.get("x-api-key");
  if (!key) {
    return Response.json({ error: "Missing API key" }, { status: 401 });
  }

  const account = await db.apiKeys.findByKey(key);
  if (!account) {
    return Response.json({ error: "Invalid API key" }, { status: 401 });
  }

  return account;
}

The authenticate function wraps whichever strategy you use. Handlers don't know or care how verification works. They just call authenticate and check the result.

Public and protected routes

Not every route needs auth. A health check, a login endpoint, a public API are all unauthenticated. Auth is per-handler, not global:

// Public — no auth
route.get("/health", {
  resolve: () => Response.json({ status: "ok" }),
});

// Public — this is where you issue tokens
route.post("/login", {
  request: {
    body: z.object({ email: z.string().email(), password: z.string() }),
  },
  resolve: async (c) => {
    if (!c.input.ok) {
      return Response.json({ error: c.input.issues }, { status: 400 });
    }

    const user = await db.users.findByEmail(c.input.body.email);
    if (!user || !(await verifyPassword(c.input.body.password, user.passwordHash))) {
      return Response.json({ error: "Invalid credentials" }, { status: 401 });
    }

    const token = await createToken(user);
    return Response.json({ token });
  },
});

// Protected — requires auth
route.get("/users", {
  resolve: async (c) => {
    const caller = authenticate(c.request);
    if (caller instanceof Response) return caller;

    return Response.json({ users: await db.users.list() });
  },
});

If you forget to call authenticate in a handler, that handler is public. This is explicit. No middleware that silently protects routes. No config file that maps paths to auth requirements. You see auth in the handler or you don't.

Token expiration and refresh

JWTs should expire. Short-lived access tokens (15 minutes to 1 hour) limit the damage if a token is stolen.

But short expiration means users get logged out frequently. Refresh tokens solve this:

  1. User logs in. Server issues an access token (short-lived) and a refresh token (long-lived).
  2. Client uses the access token for API calls.
  3. Access token expires. Client sends the refresh token to get a new access token.
  4. Refresh token eventually expires. User logs in again.
route.post("/login", {
  resolve: async (c) => {
    // ... verify credentials ...

    const accessToken = await createToken(user, "15m");
    const refreshToken = await createRefreshToken(user);

    return Response.json({ accessToken, refreshToken });
  },
});

route.post("/refresh", {
  request: {
    body: z.object({ refreshToken: z.string() }),
  },
  resolve: async (c) => {
    if (!c.input.ok) {
      return Response.json({ error: c.input.issues }, { status: 400 });
    }

    const session = await db.refreshTokens.verify(c.input.body.refreshToken);
    if (!session) {
      return Response.json({ error: "Invalid refresh token" }, { status: 401 });
    }

    const user = await db.users.get(session.userId);
    const accessToken = await createToken(user, "15m");

    return Response.json({ accessToken });
  },
});

The access token is stateless (JWT, verified by signature). The refresh token is stateful (stored in the database, can be revoked). This gives you the speed of JWTs with the revocability of sessions.

401 vs 403

These get mixed up constantly.

401 Unauthorized — "I don't know who you are." No token, invalid token, expired token. The client should authenticate (log in, refresh token) and try again.

403 Forbidden — "I know who you are, but you can't do this." Valid token, identified user, insufficient permissions. Re-authenticating won't help. The user simply doesn't have access.

// 401: authentication failed
return Response.json({ error: "Invalid token" }, { status: 401 });

// 403: authorization failed
return Response.json({ error: "Admin access required" }, { status: 403 });

If there's no token at all, that's 401 (not authenticated), not 403 (not authorized). You can't check permissions for someone you haven't identified.

Testing auth

Test each access level:

describe("DELETE /users/:id", () => {
  const id = "550e8400-e29b-41d4-a716-446655440000";

  it("returns 401 without a token", async () => {
    const res = await app.request(`/users/${id}`, { method: "DELETE" });
    expect(res.status).toBe(401);
  });

  it("returns 401 with an invalid token", async () => {
    const res = await app.request(`/users/${id}`, {
      method: "DELETE",
      headers: { authorization: "Bearer invalid" },
    });
    expect(res.status).toBe(401);
  });

  it("returns 403 for non-admin users", async () => {
    const res = await app.request(`/users/${id}`, {
      method: "DELETE",
      headers: { authorization: "Bearer user-token" },
    });
    expect(res.status).toBe(403);
  });

  it("returns 204 for admin users", async () => {
    const res = await app.request(`/users/${id}`, {
      method: "DELETE",
      headers: { authorization: "Bearer admin-token" },
    });
    expect(res.status).toBe(204);
  });
});

Unit test the auth function directly:

describe("authenticate", () => {
  it("returns 401 for missing header", () => {
    const result = authenticate(new Request("http://localhost"));
    expect(result).toBeInstanceOf(Response);
    expect((result as Response).status).toBe(401);
  });

  it("returns User for valid token", () => {
    const result = authenticate(
      new Request("http://localhost", {
        headers: { authorization: "Bearer valid-token" },
      }),
    );
    expect(result).not.toBeInstanceOf(Response);
    expect((result as User).id).toBeDefined();
  });
});

Security basics

A few things that matter regardless of your auth strategy:

Use HTTPS. Tokens sent over HTTP are visible to anyone between the client and server. No exceptions.

Don't log tokens. Your request logger should redact the Authorization header. A leaked log file becomes a leaked credential.

Hash passwords. Never store passwords in plain text. Use bcrypt or Argon2. Never write your own hashing.

Set token expiration. Every token should expire. Access tokens: 15 minutes to 1 hour. Refresh tokens: days to weeks. API keys: no expiration but revocable.

Validate token claims. After verifying the signature, check that exp hasn't passed, iss is your server, aud is your app. Don't trust the payload just because the signature is valid.

Summary

Concept What it means
Authentication Verifying identity: "who are you?"
Authorization Checking permissions: "can you do this?"
401 Not authenticated. Unknown identity.
403 Not authorized. Known identity, insufficient permissions.
JWT Signed JSON payload. Stateless. Not encrypted. Not revocable.
Refresh token Long-lived token stored server-side. Used to get new access tokens.
Bearer token Sent in Authorization: Bearer <token> header.
API key Static credential for server-to-server communication.
T | Response The auth function pattern: returns typed data or an error Response.
instanceof Response How you check which one you got. TypeScript narrows the type.

Auth is two plain functions. authenticate returns User | Response. Authorization functions return true | Response. Check with instanceof. Every decision is visible in the handler. No middleware. No magic.