Whetstone.
Multi-Tenant AuthIntroduction to auth, and authentication
Module 5, Lesson 118 min

Introduction to auth, and authentication

Multi-tenant auth is where this domain stops being about shipping and starts being about not leaking. It is also unusually clean conceptually, which is good news: the split here is sharper than in most systems you have worked in.

The sentence to hold

Authentication verifies identity through the @auth.authenticate handler. Authorization determines access permissions via the @auth.on handlers.

Learn it with the handler names attached, because a question that gives you a handler and asks what it does is the same question as one that gives you a job and asks which handler. Two words that sound alike, get abbreviated to authn and authz precisely because they sound alike, and then get tested against each other.

Where the policy lives

The handler module is wired in through the auth key in langgraph.json, and the shape of that key is worth noticing:

Auth is a pointer at your code
{ "auth": { "path": "./src/auth.ts:auth" } }

That is a module path, exactly like the graphs key. Your auth policy is code the server loads and calls, not a declarative rule set the platform evaluates. That design choice is why arbitrary policy is expressible at all, and it is why the next lesson is about return values rather than about a permissions matrix.

Authenticate: one question, once

@auth.authenticate answers who is this.

A request arrives with a token, a header, whatever your scheme uses. This handler turns that into an identity, and whatever it establishes is what every downstream decision reasons about. One question, one answer, once per request.

Authorize: many questions, fanned out

@auth.on answers what may they touch, and that is not one question. May they read this thread? Create a run? Write to the store? Delete a cron? Each is a separate decision about the same identity.

The confusion that generates distractors

Here is the move the exam makes, and it is fair, because it happens in real code constantly.

Tokens carry claims. Claims often look like permissions: a role, a workspace id, a tenant. So it feels natural to say “the token already knows they are an admin, so the authenticating handler should reject non-admins.”

Reading a claim is not making an access decision. authenticate extracts the identity and whatever travels with it. on decides whether that identity may do this specific thing to this specific resource. Same information, different job, different handler. Hold that line and you will not be caught by an option that puts an access check inside the authenticating handler, and that option will absolutely be on the page.

The same thing in TypeScript

These are written as Python decorators, and a decorator is a Python language feature, so the TypeScript surface cannot look like @auth.on. It does not. What it does instead is a chained builder on an Auth class, and the mapping is one to one:

src/auth.ts
import { Auth, HTTPException } from "@langchain/langgraph-sdk/auth";

export const auth = new Auth()
  .authenticate(async (request: Request) => {
    const token = request.headers.get("authorization");
    const user = await validateWithYourProvider(token);
    if (!user) throw new HTTPException(401, { message: "Invalid token" });
    return { identity: user.id, permissions: user.scopes };
  })
  .on("threads", ({ user }) => {
    return { owner: user.identity };
  });

That file is what the auth.path key points at, which is why the config example above reads "./src/auth.ts:auth".

Four things to read off it:

  • .authenticate() takes a plain web Request. Not a framework-specific request object; the standard one. So header reading is request.headers.get(...).

  • The returned identity uses snake_case fields even in TypeScript: identity is the only required one, alongside permissions, display_name and is_authenticated. is_authenticated defaults to true and display_name defaults to the identity, so a minimal return is genuinely just { identity }. You may also return a bare string, which is treated as the identity. This is the detail most likely to catch a TypeScript developer out, because every instinct says isAuthenticated.

    Note which way that default leans. Returning at all is what authenticates the caller, so a handler that returns an identity on a path where verification did not actually succeed has authenticated them. is_authenticated: false is something you opt into, not a state you fall back to. The next lesson’s rule follows from this: when validation cannot complete, throw rather than return.

  • .on() takes the event as a string argument rather than as a decorator target, and that string is where the specificity ladder lives: "*", a resource like "threads", an action like "*:create", or a specific event like "threads:create". Same ladder, expressed as an argument instead of as a decorator name.

  • Rejection is a thrown HTTPException, which is exported from the same module.

There is one more export worth knowing by name: isStudioUser(), which tells you whether a request came from LangGraph Studio. Custom authorization applies to Studio traffic too by default, so without it your own policy can lock you out of your own Studio.

Practice

Try it yourself

Recall

The quotable split

The documentation draws this line in one sentence with both handler names built into it. Reproduce it exactly.

What does authentication do and through which handler, and what does authorization do and through which handlers?

Reveal answer

Authentication verifies identity through the @auth.authenticate handler. Authorization determines access permissions via the @auth.on handlers. Two jobs, two handler families: one handler answers who is this, and a family of handlers answers what may they touch. Note the shape difference as well, because it is a hint about the mechanics: authenticate is singular, on is a family with sub-levels.

Quiz

Which handler family owns this decision

A request arrives carrying a bearer token. You need to reject users who are not members of the workspace that owns the thread. Which family does that work belong in?

  1. A@auth.authenticate, because it inspects the token and the token carries workspace membership
  2. BBoth, since membership is identity and permission at the same time
  3. C@auth.on, because deciding what an identified user may access is authorization
  4. DNeither; workspace scoping is configured in langgraph.json rather than in handlers
Show answer

Correct answer: C — @auth.on, because deciding what an identified user may access is authorization

Establishing who the caller is belongs to @auth.authenticate. Deciding whether that established identity may touch this particular resource is authorization, so it belongs to @auth.on. The tempting wrong answer is the first, because the membership fact does physically arrive inside the token, so it feels like something the authenticating handler should act on. Reading a claim is not the same as making an access decision: authenticate extracts identity, on decides what that identity is allowed to do with it. The last option is not absurd either, since auth genuinely is wired in through a config key, but the key points at handler code rather than expressing the policy itself.

Quiz

What the auth key actually declares

This is the wiring, and it tells you where the policy lives.

The auth key in langgraph.json
{ "auth": { "path": "./src/auth.ts:auth" } }
  1. AA list of permitted API keys checked by the server before your code runs
  2. BAn identity provider's issuer URL, from which the server derives validation rules
  3. CA role-to-permission mapping the platform evaluates before your code runs
  4. DA module path to your own handler code, which the server loads and calls
Show answer

Correct answer: D — A module path to your own handler code, which the server loads and calls

The auth key points at a module in your codebase, exactly the way the graphs key points at a graph entrypoint. Your handlers are code the server loads and calls, which is what makes arbitrary policy expressible. The tempting wrong answer is the issuer URL, because that is precisely how you would configure auth in most managed platforms and because an identity provider genuinely does sit behind this in production: it sits behind your handler, reached by code you wrote, rather than being declared in the config. Both remaining options describe declarative policy, which is the model this deliberately is not.

Recall

Why one is singular and the other is a family

The asymmetry in the naming is not cosmetic, it is a hint about how each side has to work.

Why is there one authenticate handler but a whole family of on handlers?

Reveal answer

Because identity is one question asked once per request: who is this. Access is many questions, one per resource and action: may they read this thread, may they create a run, may they write to the store. Authentication converges on a single answer that everything downstream shares, while authorization fans out across resources and actions. That is exactly why the on handlers have a specificity ladder and authenticate does not, and why an option placing an access check inside the authenticating handler is describing the wrong shape of system.

Recall

The same handlers in TypeScript

The exam covers both languages, so learn the mapping rather than only the side you type.

The handler names are written as Python decorators. What is the TypeScript equivalent, and what carries across unchanged?

Reveal answer

TypeScript uses a chained builder rather than decorators, because a decorator is a Python language feature: import { Auth, HTTPException } from "@langchain/langgraph-sdk/auth", then new Auth().authenticate(handler).on(event, handler). The semantics carry across exactly: one authentication entry point, a family of authorization entry points, resolved by specificity, wired in through the auth key. The ladder moves from the decorator name into a string argument, so @auth.on("threads.create") becomes .on("threads:create"). Two details worth memorising: .authenticate() receives a standard web Request, and the identity object keeps snake_case fields even in TypeScript, so it is is_authenticated and display_name, never isAuthenticated. Only identity is required. Rejection is a thrown HTTPException.

Sign in to track your progress →