User post-authentication workflow
Workflows
Machine-to-machine (M2M) access is essential for background jobs, webhooks, and internal services. In this guide, we will make M2M access tenant-safe by default, with Kinde.
We will also add an org_code property to your M2M application, use an M2M Token Generation workflow to stamp org context into the JWT, and verify those claims in your API so each request only reaches its own organization’s data.
The result: clean auditability, easy revocation, and strong isolation across orgs without adding unnecessary complexity.
Fork the workflow base template repo into your GitHub account by selecting Use this template > Create a new repository.
Clone the repo into your computer with the following Git command:
git clone https://github.com/your_github_username/workflow-base-template.gitChange into the directory:
cd workflow-base-templateRemove the sample workflow with the command:
rm -rf kindeSrc/environment/workflows/postUserAuthenticationCreate a new workflow with the following command.
You can name it anything that resonates with your project structure; we are calling it m2mCustomClaims
mkdir kindeSrc/environment/workflows/m2mCustomClaimstouch kindeSrc/environment/workflows/m2mCustomClaims/Workflow.tsOpen the Workflow.ts file with your favorite code editor (e.g, VS Code), add the following code into the file, and save changes:
import { onM2MTokenGeneratedEvent, WorkflowSettings, WorkflowTrigger, createKindeAPI, m2mTokenClaims,} from "@kinde/infrastructure"
export const workflowSettings: WorkflowSettings = { id: "m2mTokenGeneration", name: "M2M custom claims", failurePolicy: { action: "stop", }, trigger: WorkflowTrigger.M2MTokenGeneration, bindings: { "kinde.m2mToken": {}, // required to modify M2M access token "kinde.fetch": {}, // Required for API calls "kinde.env": {}, // required to access your environment variables url: {}, // required for url params },}
export default async function Workflow(event: onM2MTokenGeneratedEvent) { // Get a token for Kinde management API const kindeAPI = await createKindeAPI(event)
const { clientId } = event.context.application
// Call Kinde applications properties API const { data } = await kindeAPI.get({ endpoint: `applications/${clientId}/properties`, }) const properties = data?.properties ?? []
// Get the org code property to make the correlation const orgProp = properties.find((p: any) => p.key === "org_code") if (!orgProp?.value) { throw new Error("Missing org_code application property") }
// Get org data from Kinde management API const { data: orgsData } = await kindeAPI.get({ endpoint: "organizations", })
const organizations = orgsData?.data?.organizations ?? orgsData?.organizations ?? [] const org = organizations.find((o: any) => o.code === orgProp.value) if (!org) { throw new Error(`No organization found with code '${orgProp.value}'.`) }
// set up types for the custom claims const m2mToken = m2mTokenClaims<{ applicationId: string orgName: string orgCode: string }>()
// Use the data to set the org data on the M2M token m2mToken.applicationId = clientId m2mToken.orgName = org.name m2mToken.orgCode = org.code}Optional: Install the Kinde infrastructure dependency with the following bash command for TypeScript intellisense.
npm installRun the following git commands in your terminal to push the changes to your GitHub repo:
git add .git commit -m "add M2M custom claims workflow"git pushSign in to your Kinde dashboard and select Workflows from the sidebar.
If you already have your workflow repo connected, go straight to step 4.
Select Connect repo → Connect GitHub and follow the onboarding flow to authorize Kinde to access your GitHub account.
From the dropdown, select your GitHub repository that contains the Kinde workflow code, choose the main branch, and click Next.
If you already have a repo connected and want to change it, select Change repo.
Select Sync code to pull your latest workflow code from GitHub into your Kinde project. You’ll now see your workflow listed inside the dashboard.
Sign in to Kinde and on the front page, select Add application.
Enter a name for the application (e.g., M2M Custom Claims Workflow) and select Machine to machine (M2M) as the application type.
Select Save. The details page opens.
Take note of the Client ID and Client secret.
Go back Home, then go to Settings > Env variables and select Add environment variable, a popup will open.
Enter KINDE_WF_M2M_CLIENT_ID for the Key and the Client ID of your M2M application for the value. You can find them under your M2M Application > Details page. Select Save.
Create a new Env variable for KINDE_WF_M2M_CLIENT_SECRET, enter the Client secret, and enable the Sensitive option. Select Save.
Go back to Kinde home > Your M2M Application > select View details
Go to APIs in the side menu.
Select the three dots next to the Kinde Management API, and select Authorize application.
Select the three dots again and select Manage scopes.
Enable the following scopes and select Save.
read:application_properties
read:applications
read:organizations
Go to Kinde home > Organizations, and select Add organization.
Enter the name and handle of the organization, select Save.
Copy the generated Organization Code. You will need it in the next steps. For example, org_8edbI09lq51.
Go to Settings > APIs > Add API.
Type a name for the API (e.g., Acme)
Enter acme in the Audience field and select Save. This will enable your app to add a custom audience claim in your JWT for security.
Go to Settings > Properties, and select Add property.
Enter a Name of the property (e.g., Org code)
Enter the Key org_code
Disable the Private switch. This is important so the property can be used in the token.
Scroll down and select Applications in the Definition section.
Select Save.
Go to Kinde dashboard, select Add application.
Enter a name for the application (e.g., Acme M2M Client Application) and select Machine to machine (M2M) as the application type.
Select Save. The details page opens.
Take note of the Client ID and Client secret. You will need it to test your application below.
Go to APIs, you will see the Acme API you created earlier.
Select the three dots next to your Acme API and select Authorize application.
This will add acme into your Kinde JSON web token inside the aud field.
Go to Properties, select Edit on the Org Code property, a pop-up will open.
Enter the Org Code you previously copied from the Organization page (org_xxx) and select Save.
Go to Tokens, and select Customize under the M2M Token section. A pop-up will appear.
Enable the Org Code and select Save.
Make a cURL request by running the following code in your terminal.
curl https://<your_kinde_domain>.kinde.com/oauth2/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d grant_type=client_credentials \ -d client_id=<your_acme_m2m_client_id> \ -d client_secret=<your_acme_m2m_client_secret> \ -d audience=acmeYou should receive a JSON response with the JWT with custom claims:
{ "access_token": "the_jwt_code...", "expires_in": 86399, "scope": "", "token_type": "bearer"}Decode the JWT to find the custom claims:
{ "applicationId": "47dfff23348d4acc84ab2770a0fa877d", "application_properties": { "org_code": { "v": "org_8ed648340ea9" } }, "aud": [ "acme" ], "azp": "47dfff23348d4acc84ab2770a0fa877d", "exp": 1755011382, "gty": [ "client_credentials" ], "iat": 1754924982, "iss": "https://kindetestingtamal.kinde.com", "jti": "f2cba5a5-12f4-44c4-8311-119ba65d8ca5", "orgCode": "org_8ed648340ea9", "orgName": "Acme Inc", "scope": "", "scp": [], "v": "2"}Go to Kinde > Workflows > M2M custom claims > Runtime logs and select the latest time stamp, and you should see the success message:
Now you can use the custom claims to give metered access to your API based on individual M2M clients. Here is a practical example of how you can implement it in an Express backend:
requireOrg.ts middleware:
import { createRemoteJWKSet, jwtVerify } from "jose"import type { Request, Response, NextFunction } from "express"
const ISSUER = "https://YOUR_DOMAIN.kinde.com"const AUD = "acme"const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`))
export async function requireOrg( req: Request, res: Response, next: NextFunction) { try { const token = req.headers.authorization?.split(" ")[1] if (!token) return res.status(401).send("Missing token")
const { payload } = await jwtVerify(token, JWKS, { issuer: ISSUER, audience: AUD, }) const orgCode = (payload as any).orgCode if (!orgCode) return res.status(403).send("Missing orgCode claim")
// If using path-scoped routes, enforce match: if (req.params.orgCode && req.params.orgCode !== orgCode) { // 404 avoids leaking that another org exists return res.status(404).send("Not found") }
;(req as any).org = { code: orgCode, name: (payload as any).orgName } next() } catch { return res.status(401).send("Invalid or expired token") }}server.ts with Prisma ORM:
app.get("/items", requireOrg, async (req, res) => { const org = (req as any).org.code const items = await prisma.item.findMany({ where: { orgCode: org } }) res.json(items)})You’ve configured org-aware M2M tokens and updated your API to return only the caller’s organization data. With this foundation in place, you can extend the pattern by adding per-org scopes and roles, rate limiting, and usage metering keyed by orgCode, and (optionally) database row-level security so isolation is enforced even if an application bug slips through.
Keep your secrets rotated, rely on JWKS for key rotation, and log applicationId + orgCode for traceability.