Auth

Tell Graft who is calling, using the login your app already has.

Runtime data4 min read

needs Postgres

When someone calls one of your typed functions, or an agent connects over MCP, Graft needs to know who they are. It uses that to decide what they may do, and to record who did what in the audit log.

Graft does not create accounts or log anyone in. Your app, or an identity provider such as Auth0, Clerk, or WorkOS, already does that and gives the signed-in user a token. Graft checks that token on each request and turns it into the caller's identity, which it calls the actor.

This page gets you a working identity locally in one step, then connects your real identity provider for production, then shows how to require permissions.

How Graft decides who is calling

Each request falls into one of four cases:

The request carriesGraft treats the caller as
A valid token from an identity provider you trustThe token's subject, with the scopes the token lists. It is recorded as an agent unless the issuer is set with actorKind: "human" (GRAFT_TRUSTED_ACTOR_KIND=human for graft serve)
Your local development token (GRAFT_DEV_TOKEN)You, with the scopes you set
No tokenAnonymous
A token that is expired, forged, or from an issuer you do not trustRefused with TOKEN_INVALID (401)

The last row matters. A bad token is never quietly treated as anonymous, so a broken login shows up as an error instead of as a signed-out user who can still reach public functions.

Try it locally with a development token

Set a token and the scopes it should carry in your .env:

.env
GRAFT_DEV_TOKEN=dev-secret
GRAFT_DEV_SCOPES=submissions:read,submissions:admin

Requests that send Authorization: Bearer dev-secret now act as you, with those scopes. Use this only on your machine, never in a deployment.

Connect your identity provider

In production, Graft checks tokens from identity providers you trust. They must be OpenID Connect providers, which almost all of them are.

If you run graft serve, list each provider's issuer URL, and the audience your provider puts in tokens for this API:

.env
GRAFT_TRUSTED_ISSUERS=https://your-tenant.auth0.com/
GRAFT_TRUSTED_AUDIENCE=https://api.example.com
# Optional. Record callers as people in the audit log. The default is agent.
GRAFT_TRUSTED_ACTOR_KIND=human

Graft looks up each provider's public keys and checks every token's signature, issuer, and expiry. Scopes are read from the token's scope, scopes, or permissions claim.

Two settings apply to every issuer in the list:

  • GRAFT_TRUSTED_AUDIENCE is the token audience (aud) Graft accepts. List more than one with commas. A token minted for another of your apps carries a different audience and is refused. If you leave it unset, the audience is not checked and any token the provider signs is accepted. graft serve prints a warning at startup when that happens.
  • GRAFT_TRUSTED_ACTOR_KIND is what the audit log records callers as: agent (the default) or human. Any other value stops graft serve from starting, with INPUT_VALIDATION_FAILED.

If your issuers need different audiences or actor kinds, run Graft inside your app and set them per issuer, as below.

If Graft runs inside your own app, create the resolver in code and pass it to Graft's handlers. None of the GRAFT_TRUSTED_* variables are read automatically here, so pass the issuers and their settings yourself:

lib/graft-auth.ts
import { createActorResolver } from "@usegraft/auth";

// A dev token never expires, so it is accepted only in development. An unset
// NODE_ENV counts as production, so a deploy that forgets it stays closed.
const devToken = process.env.NODE_ENV === "development" ? process.env.GRAFT_DEV_TOKEN : undefined;

export const actor = createActorResolver({
  issuers: (process.env.GRAFT_TRUSTED_ISSUERS ?? "")
    .split(/[,\s]+/)
    .filter(Boolean)
    .map((issuer) => ({
      issuer,
      // Only tokens minted for this app. Without it, any token from the
      // provider is accepted.
      audience: "https://api.example.com",
      // These tokens are people signing in. The default, "agent", suits
      // tokens your agents use.
      actorKind: "human",
    })),
  devTokens: devToken
    ? {
        [devToken]: {
          kind: "human",
          id: "owner",
          scopes: ["content:write"],
        },
      }
    : undefined,
});

Pass actor to createFunctionsHandler and createGraftMcpHandler. Without any issuers, every real token is refused with TOKEN_INVALID.

If your app hosts its own Better Auth instance, use betterAuthIssuer({ url }) in place of a plain { issuer }. The Next.js example app does this. It is one option, not a requirement: any OpenID Connect provider works the same way.

The same provider can sign your team in to a hosted Studio, next to GitHub and Google, so people use one login for your app and for Studio. See Host Studio for your team.

Require a permission

Add an access rule to a function so only callers with a given scope can run it:

graft.config.ts
import { requireScopes } from "@usegraft/auth";

export const recentSubmissions = defineFunction({
  name: "recentSubmissions",
  kind: "query",
  access: requireScopes("submissions:read"),
  // …
});

Anonymous callers and callers without the scope are refused. To let anyone call a function, such as a public contact form, set public: true instead. Functions & access lists the defaults.

Identity for agents

How an agent is identified depends on how it connects:

  • On your machine (graft mcp). The server uses GRAFT_DEV_TOKEN as the agent's identity. The token stays in the server, so it never appears in the agent's conversation.
  • Over HTTP (POST /api/mcp). The agent sends its token in the Authorization header of every request. The endpoint keeps no session, so a token sent only when the agent first connects does not cover later calls. MCP clients send the header on every request when it is set in their config. A single call can pass a different token to act as another user, if that token is valid.

Connecting agents covers both setups.

Next steps