Auth docs
Getting started

Quickstart.

Sign-up and sign-in in seven steps.

By the end you have an app, a signed-up user, and a token your own backend verifies. Every step shows the Node SDK first and the same call with curl. Plan ten minutes.


Before you start

  • A ProductCraft workspace with Auth turned on. Sign up at console.productcraft.co if you don't have one.
  • A workspace API key (pcft_live_…) with the auth.* actions: in the console go to Workspace → API keys → New API key. The key is shown once, so put it in an environment variable straight away.
  • Your workspace id: the id this call returns for your workspace: curl https://api.platform-auth.productcraft.co/v1/workspaces -H "authorization: Bearer $PRODUCTCRAFT_API_KEY".
  • For the SDK: Node 18 or later and npm install @productcraft/auth.
.env
PRODUCTCRAFT_API_KEY=pcft_live_...
PRODUCTCRAFT_WORKSPACE_ID=<your-workspace-id>

Step 1

Create an app

An app is one pool of users with its own roles, settings and signing keys. You can also create it in the console (Auth → New app); in code it is one call.

Pick the slug with care: it is part of every sign-in URL and of the iss and aud claims of every token, so changing it later breaks existing tokens and integrations. This guide uses acme.

setup.ts
import { Auth } from "@productcraft/auth";

// Your workspace API key: it manages apps, so keep it on the server.
// Reads PRODUCTCRAFT_API_KEY from the environment
const admin = new Auth();

const app = await admin.apps.create({
  slug: "acme",
  display_name: "Acme",
  workspace_id: process.env.PRODUCTCRAFT_WORKSPACE_ID!,
});

The response is the app, including its id (used in step 2). Three roles come with it: owner, admin and member; new users get member.


Step 2

Turn on sign-up

New apps start with sign-up off, so a sign-up call is refused (403) until you switch it on. In the console: the app's Settings → Sign-in policy → Signup enabled. In code:

setup.ts
await admin.app(app.id).authConfig.update({ signup_enabled: true });

The other defaults suit most products: passwords need 12+ characters with an uppercase letter and a number, sessions last 7 days, access tokens 1 hour. Change them on the same settings page when you need to.

Want an invite-only product instead? Leave sign-up off and invite users.


Step 3

Sign up your first user

Sign-up and sign-in are public calls: no key, just the app slug. Run them from your backend or straight from your frontend.

signup.ts
import { Auth, type ConsumerTokenResponseDto } from "@productcraft/auth";

const consumer = new Auth().consumer("acme");

const tokens = (await consumer.auth.signup({
  email: "ada@example.com",
  username: "ada",
  password: "CorrectHorse42Battery",
  display_name: "Ada Lovelace",
})) as ConsumerTokenResponseDto;
Response
{
  "access_token":  "eyJhbGc...",
  "refresh_token": "eyJhbGc...",
  "token_type": "Bearer",
  "expires_in": 3600
}

A password that misses the policy returns 400 with a message naming the rule. The email starts unverified; the verification guide covers confirming it.

Keep bots out of sign-up. A public sign-up form attracts bots that sign up with other people's addresses, and every one of those accounts is sent a verification email. Turn on Bot protection in the app's Auth settings (Cloudflare Turnstile or hCaptcha: paste the site key and the secret key), render the provider's widget on your sign-up page, and send its response as bot_protection_token. Auth checks the token with the provider before it looks at the email or password. No token is 400 BOT_PROTECTION_REQUIRED, a rejected one is 403 BOT_PROTECTION_FAILED, and if the provider can't be reached the sign-up is refused with 503 BOT_PROTECTION_UNAVAILABLE rather than let through. Tokens are single-use, so reset the widget after a failed attempt.

If your backend creates the accounts, turn on signup_requires_pak instead (or as well): sign-up then needs a workspace API key, so nobody can call it directly. A backend that also uses bot protection forwards the token its browser received.

POST /acme/v1/auth/signup (bot protection on)
curl https://api.auth.productcraft.co/acme/v1/auth/signup \
  -H 'content-type: application/json' \
  -d '{
    "email": "ada@example.com",
    "username": "ada",
    "password": "CorrectHorse42Battery",
    "bot_protection_token": "<the widget response>"
  }'

Step 4

Sign in

identifier takes the username or the primary email. The response has the same shape as sign-up.

signin.ts
const result = await consumer.auth.signin({
  identifier: "ada",
  password: "CorrectHorse42Battery",
});
if (!("access_token" in result)) {
  // The user has two-step sign-in (MFA) on: result is a challenge.
  throw new Error("This user has MFA on: finish the challenge first.");
}

A wrong password and an unknown user both return 401 Invalid credentials, so nobody can probe which usernames exist. A suspended user gets 403, and too many failed attempts get 429.


Step 5

Read the user's profile

Call /me with the user's access token. It answers who is signed in.

me.ts
// Bind an Auth instance to the user's access token.
const asUser = new Auth({
  auth: { type: "bearer", token: tokens.access_token },
});
const me = await asUser.consumer("acme").me.getProfile();
Response (excerpt)
{
  "id": "648616c8-...",
  "username": "ada",
  "display_name": "Ada Lovelace",
  "role": "member",
  "email": "ada@example.com",
  "email_verified_at": null
}

For what the user may do, call GET /acme/v1/me/permissions with the same token; it returns the role and its permission list. The roles guide covers defining your own.


Step 6

Verify tokens in your backend

Don't call /me on every request. Verify the access token locally against the app's public keys (JWKS): the SDK fetches and caches them for you.

verify.ts
// Checks signature, expiry, issuer and audience for this app.
const claims = await consumer.verifyToken(tokens.access_token);
console.log(claims.sub, claims.role);

Each app has its own signing keys. Pin the issuer https://api.auth.productcraft.co/acme and the JWKS URL https://api.auth.productcraft.co/acme/.well-known/jwks.json to your app, so a token from another app can never pass. Any JWT library works the same way (PyJWT, jsonwebtoken, golang-jwt); the token verification guide has more.


Step 7

Refresh and sign out

Access tokens last an hour. Trade the refresh token for a new pair; sign out by revoking the session.

session.ts
const next = await consumer.auth.refresh({ refresh_token: tokens.refresh_token });

// Later, to sign out:
await consumer.auth.logout({ refresh_token: next.refresh_token });

Every refresh replaces the refresh token. Reusing an old one after a 60-second grace period is treated as theft and ends the whole session. After sign-out, Auth rejects the access token at once (401); a backend that only verifies locally keeps accepting it until it expires, at most an hour.


From your backend

Call your app with an app key

Admin calls from your server (list, read and update users) use an app key. It belongs to one app and can do only what its permissions allow.

In the console, open your app: Get started → Quickstart shows this code with your app's values, and Mint a key for this creates a key that can list and read users and puts it in the code, so it runs as pasted. Keys with other permissions are under Integration → API keys. Keys on a production app start with hdk_live_, on a staging or development app with hdk_test_.

Install
npm install @productcraft/auth
.env
PRODUCTCRAFT_AUTH_APP_KEY=your-app-key
auth.ts
import { Auth } from "@productcraft/auth";

// Sign-up, sign-in and token checks are public: no key needed.
const consumer = new Auth().consumer("acme");

// Verify the access token your frontend sends. Checks the signature,
// expiry, issuer (https://api.auth.productcraft.co/acme) and audience.
export async function currentUser(accessToken: string) {
  const claims = await consumer.verifyToken(accessToken);
  return { id: claims.sub, role: claims.role };
}

// Admin calls from your server use the app key.
export async function listUsers() {
  const res = await fetch("https://api.auth.productcraft.co/acme/v1/admin/users?limit=5", {
    headers: { authorization: `Bearer ${process.env.PRODUCTCRAFT_AUTH_APP_KEY}` },
  });
  if (!res.ok) throw new Error(`Auth answered ${res.status}`);
  return res.json();
}

A key without the permission a call needs gets 403, a key for another app or a revoked key gets 401. The workspace API key (pcft_live_…) from the steps above is a different credential: it manages apps, not one app's users.


Instead of sign-up

Invite users

For an invite-only product, leave sign-up off. Your backend creates an invite with the workspace API key; Auth emails the code and also returns it. The user accepts with a password and is signed in.

invite.ts
const invite = await admin.app(app.id).invites.create({
  email: "grace@example.com",
  role_name: "member",
});

// The user accepts with the code from the email:
const invited = await consumer.auth.acceptInvite({
  code: invite.code,
  password: "CorrectHorse42Battery",
});

These are users of your product. Teammates who help run the app are invited to your workspace in the console instead.


Next

Where to go from here