close

DEV Community

Cover image for How to Build Organization-Scoped M2M Authentication for Multi-Tenant APIs
Shola Jegede
Shola Jegede Subscriber

Posted on

How to Build Organization-Scoped M2M Authentication for Multi-Tenant APIs

A complete step-by-step guide to building multi-tenant API authentication that enforces tenant isolation at the token level — no middleware gymnastics required.

In this tutorial, you will learn:

  • What multi-tenant API authentication actually requires and where most implementations go wrong
  • How Kinde's org-scoped M2M applications work under the hood
  • How to register your API and define granular scopes in Kinde
  • How to create an org-scoped M2M application tied to a specific tenant
  • How to request an access token using the OAuth 2.0 client credentials flow
  • How to verify the token and enforce org isolation in an Express.js API
  • How to test the setup end to end
  • Common pitfalls and how to avoid them

Let's dive in!

What Is Multi-Tenant M2M Authentication and Why Does It Need Its Own Layer?

Multi-tenant SaaS products have a fundamental security requirement: one tenant must never be able to access another tenant's data. For human users, this is typically enforced through the user's session — the logged-in user belongs to an organization, and every API call carries a token that says which organization they are in.

But when the caller is not a human — when it is an automated script, a backend service, or an AI agent — the same problem exists with none of the same tools to solve it.

The common workaround is to write tenant-checking middleware that reads a tenant ID from the request body, a query param, or a custom header, then validates it against a database. This works, but it has a fundamental weakness: the tenant context comes from the caller. The caller claims to be Acme Corp. Your API has to trust that claim, look it up, and verify it. That is a manual security check sitting between you and a data breach.

The better approach is to move tenant isolation into the authentication token itself, so that the org_code of the calling machine identity is embedded by the auth server at token issuance time. By the time the token reaches your API, the org context is already cryptographically signed and verified. The caller cannot claim to be a different tenant. The token is the proof.

Kinde's org-scoped M2M applications implement exactly this pattern. Here is how to build it from scratch.

Side-by-side comparison —

Prerequisites

Before you start, make sure you have:

  • A Kinde account on the Plus or Scale plan (org-scoped M2M apps require these plans — sign up free and upgrade when ready)
  • Node.js 18 or higher installed on your machine
  • A basic understanding of Express.js and JWT tokens
  • curl or Postman for testing API calls

The Target Architecture

You are building a multi-tenant SaaS API. Each of your customers is an organization in Kinde. Each customer has an automated service or agent that needs to call your API on their behalf. By the end of this guide, your architecture will look like this:

  • Each customer organization has a dedicated M2M application in Kinde with its own client_id and client_secret
  • When the customer's service needs to call your API, it requests a token using those credentials
  • Kinde issues a JWT that includes the customer's org_code as a trusted claim
  • Your API verifies the JWT and reads org_code directly from the token, scoping all data access to that tenant without any additional lookup

Full architecture — Three customer orgs (Acme Corp, Beta Inc, Gamma Ltd) each with their own M2M app credentials → Each requests a token from Kinde → Kinde issues JWT with org_code baked in → All tokens hit the same API endpoint → API verifies JWT, reads org_code, scopes DB query to that org → No cross-tenant access possible

Step #1: Register Your API in Kinde

Before any M2M application can request a token for your API, your API needs to be registered in Kinde. This tells Kinde what the valid audience for tokens is and lets you define the scopes your API supports.

In your Kinde dashboard, navigate to APIs in the left sidebar and select Add API.

Kinde dashboard — APIs section with

Give your API a name (for example, "Multi-Tenant Data API") and an audience identifier. The audience is a string that uniquely identifies your API — convention is to use your API's base URL:

https://api.yourapp.com
Enter fullscreen mode Exit fullscreen mode

Select Save. Your API is now registered in Kinde.

Note: The audience value you set here must exactly match the audience parameter you send in token requests later. They are case-sensitive.

Step #2: Define API Scopes

Scopes define the permissions that M2M applications can be granted when calling your API. Rather than giving every M2M app blanket access to everything, scopes let you express exactly what each service is allowed to do.

In your API settings, select the Scopes tab and add the scopes your API needs. For a typical multi-tenant data API, a reasonable starting set looks like this:

Kinde dashboard — API detail page > Scopes tab, showing the

Scope Description
read:data Read records belonging to the organization
write:data Create and update records for the organization
delete:data Delete records belonging to the organization
read:users Read users within the organization

Add each scope with a name and a short description. The description will appear in your Kinde dashboard and helps your team understand what each scope permits.

Select Save after adding all scopes.

Step #3: Set Up Your Node.js Project

Create a new directory for your API project and initialize it:

mkdir kinde-m2m-api
cd kinde-m2m-api
npm init -y
Enter fullscreen mode Exit fullscreen mode

Install the required dependencies:

npm install express jsonwebtoken jwks-rsa dotenv
Enter fullscreen mode Exit fullscreen mode

Here is what each package does:

  • express: the API framework
  • jsonwebtoken: for verifying and decoding JWTs
  • jwks-rsa: for fetching Kinde's public keys to verify token signatures
  • dotenv: for loading environment variables from a .env file

Create a .env file at the root of your project:

touch .env
Enter fullscreen mode Exit fullscreen mode

Add your Kinde configuration to it:

KINDE_DOMAIN=https://your-subdomain.kinde.com
API_AUDIENCE=https://api.yourapp.com
PORT=3000
Enter fullscreen mode Exit fullscreen mode

Replace your-subdomain with your actual Kinde subdomain and api.yourapp.com with the audience you registered in Step #1.

Create the main application file:

touch index.js
Enter fullscreen mode Exit fullscreen mode

Wonderful! The project structure is ready.

Step #4: Build the Token Verification Middleware

The core of the implementation is a piece of Express middleware that intercepts every incoming request, extracts the Bearer token from the Authorization header, verifies it against Kinde's public keys, and then makes the decoded token claims available to your route handlers.

Open index.js and start with the following:

// index.js
require("dotenv").config();
const express = require("express");
const jwt = require("jsonwebtoken");
const jwksClient = require("jwks-rsa");

const app = express();
app.use(express.json());

// Initialize the JWKS client — this fetches Kinde's public keys
// to verify the signature of incoming JWTs
const jwks = jwksClient({
  jwksUri: `${process.env.KINDE_DOMAIN}/.well-known/jwks.json`,
  cache: true,       // cache keys so we don't fetch on every request
  rateLimit: true,   // rate limit key fetches to avoid hammering Kinde
});

// Helper that retrieves the signing key matching the token's "kid" header
function getSigningKey(header, callback) {
  jwks.getSigningKey(header.kid, (err, key) => {
    if (err) return callback(err);
    callback(null, key.getPublicKey());
  });
}
Enter fullscreen mode Exit fullscreen mode

Now add the core verifyM2MToken middleware. This middleware handles three jobs: extracting the token, verifying its signature and claims, and checking that it carries an org_code:

// Middleware: verify the M2M token and extract org context
function verifyM2MToken(req, res, next) {
  const authHeader = req.headers["authorization"];
  const token = authHeader && authHeader.split(" ")[1]; // extract Bearer token

  if (!token) {
    return res.status(401).json({
      error: "Unauthorized",
      message: "No token provided",
    });
  }

  jwt.verify(
    token,
    getSigningKey,
    {
      // audience must match the API audience registered in Kinde
      audience: process.env.API_AUDIENCE,
      // issuer must match your Kinde domain
      issuer: process.env.KINDE_DOMAIN,
      algorithms: ["RS256"],
    },
    (err, decoded) => {
      if (err) {
        return res.status(403).json({
          error: "Forbidden",
          message: "Invalid or expired token",
        });
      }

      // Reject tokens that are not org-scoped
      // A global M2M token has no org_code and must not access
      // tenant-specific resources
      if (!decoded.org_code) {
        return res.status(403).json({
          error: "Forbidden",
          message: "Token is not scoped to an organization",
        });
      }

      // Attach the verified token claims to the request object
      // so route handlers can use them without re-decoding
      req.auth = {
        orgCode: decoded.org_code,
        scopes: decoded.scp ? decoded.scp.split(" ") : [],
        clientId: decoded.sub,
      };

      next();
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

Next, add a scope-checking helper. Not every endpoint should be accessible with every token. A token granted only read:data should not be able to hit a write endpoint:

// Helper: check that the token includes a required scope
function requireScope(scope) {
  return (req, res, next) => {
    if (!req.auth.scopes.includes(scope)) {
      return res.status(403).json({
        error: "Forbidden",
        message: `Token missing required scope: ${scope}`,
      });
    }
    next();
  };
}
Enter fullscreen mode Exit fullscreen mode

Step #5: Add the Org Isolation Middleware

The token verification middleware confirms that the caller has a valid token with an org_code. But for routes that include an org identifier in the path — like /orgs/:orgCode/data — you also need to confirm that the token's org_code matches the org being requested. This prevents a token issued for Acme Corp from accidentally hitting Beta Inc's endpoint.

// Middleware: enforce that the token's org matches the route parameter
// Use this on any route that includes :orgCode in the path
function enforceOrgAccess(req, res, next) {
  const routeOrgCode = req.params.orgCode;

  if (!routeOrgCode) {
    // No org code in route — nothing to enforce
    return next();
  }

  if (req.auth.orgCode !== routeOrgCode) {
    return res.status(403).json({
      error: "Forbidden",
      message: "Token does not have access to this organization",
    });
  }

  next();
}
Enter fullscreen mode Exit fullscreen mode

This is the key enforcement layer. Even if someone obtained a valid token for a different organization and deliberately changed the URL to point at another tenant's resources, this middleware will catch it and reject the request.

Step #6: Build the API Routes

Now add actual API routes that use the middleware stack you just built. Notice how each route uses verifyM2MToken first, then enforceOrgAccess, then optionally a scope check — this gives you layered defense without duplicating any logic.

// In-memory store to simulate a database
// In a real app, replace this with your actual DB queries
const mockDatabase = {
  "org_acme": [
    { id: 1, name: "Acme Record A", value: 100 },
    { id: 2, name: "Acme Record B", value: 200 },
  ],
  "org_beta": [
    { id: 1, name: "Beta Record A", value: 50 },
  ],
};

// GET /orgs/:orgCode/data
// Returns all data records belonging to the specified organization
app.get(
  "/orgs/:orgCode/data",
  verifyM2MToken,
  enforceOrgAccess,
  requireScope("read:data"),
  (req, res) => {
    const { orgCode } = req.params;

    // The org_code is already verified — safe to use directly as DB key
    const records = mockDatabase[orgCode] || [];

    res.json({
      org_code: orgCode,
      records,
      total: records.length,
    });
  }
);

// POST /orgs/:orgCode/data
// Creates a new record for the specified organization
app.post(
  "/orgs/:orgCode/data",
  verifyM2MToken,
  enforceOrgAccess,
  requireScope("write:data"),
  (req, res) => {
    const { orgCode } = req.params;
    const { name, value } = req.body;

    if (!name || value === undefined) {
      return res.status(400).json({
        error: "Bad Request",
        message: "Both 'name' and 'value' fields are required",
      });
    }

    // Initialize org's data store if this is the first record
    if (!mockDatabase[orgCode]) {
      mockDatabase[orgCode] = [];
    }

    const newRecord = {
      id: mockDatabase[orgCode].length + 1,
      name,
      value,
    };

    mockDatabase[orgCode].push(newRecord);

    res.status(201).json({
      org_code: orgCode,
      record: newRecord,
    });
  }
);

// GET /health
// Public endpoint — no authentication required
app.get("/health", (req, res) => {
  res.json({ status: "ok" });
});

// Start the server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`API running on http://localhost:${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Terrific! The API is complete. Before testing it, you need to set up the org-scoped M2M application in Kinde that will authenticate against it.

Step #7: Create an Org-Scoped M2M Application in Kinde

This step is where Kinde does the heavy lifting. You are going to create an M2M application that is tied to a specific organization — meaning any token issued for this app will automatically carry that organization's org_code.

In your Kinde dashboard, navigate to Organizations in the left sidebar.

Kinde dashboard — Organizations list page, showing multiple organizations with their names and codes

Select the organization you want to create the M2M app for. This represents one of your customers. On the organization's detail page, select the M2M apps tab.

Kinde dashboard — Organization detail page with the

Select Add M2M application. Give it a descriptive name — for example, "Acme Corp API Client" — and select M2M application as the type. Select Save.

Kinde generates a client_id and client_secret for this application. Copy and save the client_secret immediately — Kinde only shows it once.

Kinde dashboard — Newly created org-scoped M2M application detail page showing client_id, client_secret (partially obscured), and the organization it is tied to

Step #8: Authorize the M2M App for Your API

The M2M app exists, but it does not yet have permission to request tokens for your API. You need to authorize it explicitly.

Still on the M2M application detail page, select the APIs tab. Select the three-dot menu next to your registered API and select Authorize.

M2M application detail page — APIs tab showing the API with a three-dot menu open, highlighting the

After authorizing, select which scopes this application should be able to request. For this tutorial, grant read:data and write:data. Do not grant delete:data unless the application genuinely needs it.

M2M application APIs tab showing the API is now authorized with specific scopes checked

Select Save.

M2M application APIs tab showing checked scopes (read:data, write:data)

Step #9: Request an Access Token

Now you have a Kinde M2M app with credentials and authorized scopes. Time to request a token. Run the following curl command, replacing the placeholders with your actual values:

curl --request POST \
  --url https://YOUR_KINDE_SUBDOMAIN.kinde.com/oauth2/token \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=client_credentials' \
  --data-urlencode 'client_id=YOUR_CLIENT_ID' \
  --data-urlencode 'client_secret=YOUR_CLIENT_SECRET' \
  --data-urlencode 'audience=https://api.yourapp.com' \
  --data-urlencode 'scope=read:data write:data'
Enter fullscreen mode Exit fullscreen mode

Kinde responds with:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQ1In0...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "read:data write:data"
}
Enter fullscreen mode Exit fullscreen mode

Copy the access_token value. You can inspect its claims by decoding it at kinde.com/tools/online-jwt-decoder or any other JWT decoder. The decoded payload will look like this:

{
  "iss": "https://your-subdomain.kinde.com",
  "sub": "YOUR_CLIENT_ID",
  "aud": ["https://api.yourapp.com"],
  "exp": 1234567890,
  "iat": 1234567890,
  "scp": "read:data write:data",
  "org_code": "org_acme",
  "jti": "abc123"
}
Enter fullscreen mode Exit fullscreen mode

The org_code claim is there, embedded in the token by Kinde at issuance time, signed with Kinde's private key, and impossible to tamper with. Your API will verify this signature before trusting any claim in the token.

Step #10: Start the API and Test End-to-End

Start the API:

node index.js
Enter fullscreen mode Exit fullscreen mode

You should see:

API running on http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

Now test the authenticated endpoint. Replace YOUR_ACCESS_TOKEN with the token you received in the previous step and org_acme with the actual org_code from the token:

# GET data for the organization — should succeed
curl http://localhost:3000/orgs/org_acme/data \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Enter fullscreen mode Exit fullscreen mode

The response should be:

{
  "org_code": "org_acme",
  "records": [
    { "id": 1, "name": "Acme Record A", "value": 100 },
    { "id": 2, "name": "Acme Record B", "value": 200 }
  ],
  "total": 2
}
Enter fullscreen mode Exit fullscreen mode

Now test a cross-tenant access attempt. Use the same Acme Corp token but try to access Beta Inc's data:

# Attempt cross-tenant access — should be rejected with 403
curl http://localhost:3000/orgs/org_beta/data \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Enter fullscreen mode Exit fullscreen mode

The response should be:

{
  "error": "Forbidden",
  "message": "Token does not have access to this organization"
}
Enter fullscreen mode Exit fullscreen mode

Et voilà! Tenant isolation working exactly as intended — enforced at the token level, no database lookup required.

Test the write endpoint:

# POST a new record for the organization
curl --request POST http://localhost:3000/orgs/org_acme/data \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Acme Record C", "value": 350}'
Enter fullscreen mode Exit fullscreen mode

The response:

{
  "org_code": "org_acme",
  "record": { "id": 3, "name": "Acme Record C", "value": 350 }
}
Enter fullscreen mode Exit fullscreen mode

Test a request with no token to confirm unauthorized requests are rejected:

# No token — should return 401
curl http://localhost:3000/orgs/org_acme/data
Enter fullscreen mode Exit fullscreen mode
{
  "error": "Unauthorized",
  "message": "No token provided"
}
Enter fullscreen mode Exit fullscreen mode

Amazing! Every edge case is handled correctly.

Putting It All Together

Here is the complete index.js file for reference:

// index.js — complete implementation
require("dotenv").config();
const express = require("express");
const jwt = require("jsonwebtoken");
const jwksClient = require("jwks-rsa");

const app = express();
app.use(express.json());

// JWKS client — fetches Kinde's public keys for JWT signature verification
const jwks = jwksClient({
  jwksUri: `${process.env.KINDE_DOMAIN}/.well-known/jwks.json`,
  cache: true,
  rateLimit: true,
});

// Retrieve the signing key that matches the token's "kid" header
function getSigningKey(header, callback) {
  jwks.getSigningKey(header.kid, (err, key) => {
    if (err) return callback(err);
    callback(null, key.getPublicKey());
  });
}

// Middleware: verify the incoming M2M token and extract org context
function verifyM2MToken(req, res, next) {
  const authHeader = req.headers["authorization"];
  const token = authHeader && authHeader.split(" ")[1];

  if (!token) {
    return res.status(401).json({
      error: "Unauthorized",
      message: "No token provided",
    });
  }

  jwt.verify(
    token,
    getSigningKey,
    {
      audience: process.env.API_AUDIENCE,
      issuer: process.env.KINDE_DOMAIN,
      algorithms: ["RS256"],
    },
    (err, decoded) => {
      if (err) {
        return res.status(403).json({
          error: "Forbidden",
          message: "Invalid or expired token",
        });
      }

      if (!decoded.org_code) {
        return res.status(403).json({
          error: "Forbidden",
          message: "Token is not scoped to an organization",
        });
      }

      req.auth = {
        orgCode: decoded.org_code,
        scopes: decoded.scp ? decoded.scp.split(" ") : [],
        clientId: decoded.sub,
      };

      next();
    }
  );
}

// Middleware: enforce that the token's org matches the route parameter
function enforceOrgAccess(req, res, next) {
  const routeOrgCode = req.params.orgCode;
  if (!routeOrgCode) return next();

  if (req.auth.orgCode !== routeOrgCode) {
    return res.status(403).json({
      error: "Forbidden",
      message: "Token does not have access to this organization",
    });
  }

  next();
}

// Helper: gate a route behind a required scope
function requireScope(scope) {
  return (req, res, next) => {
    if (!req.auth.scopes.includes(scope)) {
      return res.status(403).json({
        error: "Forbidden",
        message: `Token missing required scope: ${scope}`,
      });
    }
    next();
  };
}

// In-memory data store (replace with real DB in production)
const mockDatabase = {
  "org_acme": [
    { id: 1, name: "Acme Record A", value: 100 },
    { id: 2, name: "Acme Record B", value: 200 },
  ],
  "org_beta": [
    { id: 1, name: "Beta Record A", value: 50 },
  ],
};

// GET /orgs/:orgCode/data
app.get(
  "/orgs/:orgCode/data",
  verifyM2MToken,
  enforceOrgAccess,
  requireScope("read:data"),
  (req, res) => {
    const { orgCode } = req.params;
    const records = mockDatabase[orgCode] || [];
    res.json({ org_code: orgCode, records, total: records.length });
  }
);

// POST /orgs/:orgCode/data
app.post(
  "/orgs/:orgCode/data",
  verifyM2MToken,
  enforceOrgAccess,
  requireScope("write:data"),
  (req, res) => {
    const { orgCode } = req.params;
    const { name, value } = req.body;

    if (!name || value === undefined) {
      return res.status(400).json({
        error: "Bad Request",
        message: "Both 'name' and 'value' fields are required",
      });
    }

    if (!mockDatabase[orgCode]) mockDatabase[orgCode] = [];

    const newRecord = {
      id: mockDatabase[orgCode].length + 1,
      name,
      value,
    };

    mockDatabase[orgCode].push(newRecord);
    res.status(201).json({ org_code: orgCode, record: newRecord });
  }
);

// GET /health — public endpoint
app.get("/health", (req, res) => {
  res.json({ status: "ok" });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`API running on http://localhost:${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Advanced Scenarios

Testing Tokens Without Writing Code

You do not have to write a token request script every time you want to test. Kinde provides a built-in token generator inside the M2M application detail page. Navigate to M2M applicationAPIs → select your API → Test tab → Get token. Copy the generated access token and use it directly in curl or Postman.

M2M application > APIs tab > Test sub-tab, showing the

This is useful during development when you want to verify scopes and claims without building a full token request flow first.

Scaling to Multiple Tenants

The pattern scales naturally. For each new customer you onboard, you create one org-scoped M2M application inside their organization in Kinde. Each gets its own client_id and client_secret. The same API code handles all of them — the only difference is the org_code in the token.

When you need to revoke access for a specific customer, you delete or disable their M2M application in Kinde. Their credentials stop working immediately. No code changes required on your API.

Rotating Client Secrets

Client secrets should be rotated periodically. Kinde makes this straightforward — on the M2M application detail page, select Rotate client secret. Kinde generates a new secret. Update your customer's secure storage (environment variable, secrets manager, etc.) with the new value, and the old secret becomes invalid.

M2M application detail page showing the

Build secret rotation into your operational runbook and do not treat it as optional.

Common Pitfalls and How to Avoid Them

Using the wrong audience in the token request. The audience parameter in the token request must exactly match the audience you registered for your API in Kinde. If they do not match, the token request will fail or the token will not be accepted by your API. Copy the audience value directly from Kinde to avoid typos.

Not rejecting tokens without org_code. If you accept tokens that do not have an org_code, you are accepting global M2M tokens that could have been issued for any purpose. Always explicitly check for the presence of org_code and reject tokens without it when the route is tenant-specific.

Extracting org context from the request body or query params. If your API reads the org_code from anywhere other than the verified JWT, you are trusting user input for a security decision. The entire point of this architecture is to trust only what is in the signed token. Never use req.body.org_code or req.query.org_code as your tenant context.

Sharing one M2M application across multiple tenants. Each tenant must have its own M2M application with its own credentials. Sharing credentials means a compromised secret gives access to multiple tenants, and it means you cannot revoke one tenant's access without affecting others.

Not checking scopes. Token verification confirms the caller is who they claim to be. Scope checking confirms they are allowed to do what they are trying to do. Both checks are necessary. A token that says org_code: org_acme but has no write:data scope should not be able to create records even if the org matches.

Skipping JWKS caching. The jwks-rsa library supports caching Kinde's public keys. Always enable this (cache: true). Without it, every token verification triggers a network request to Kinde's JWKS endpoint — this adds latency to every API call and creates a dependency on Kinde's availability for every request.

Conclusion

In this tutorial, you learned how multi-tenant M2M authentication works, why tenant isolation needs to live in the token rather than in your application logic, and how to implement it end-to-end with Kinde's org-scoped M2M applications and a Node.js Express API.

The complete flow — register your API, define scopes, create per-tenant M2M apps, request tokens, verify JWTs, and enforce org_code isolation — gives you a production-grade multi-tenant authentication layer that is secure, auditable, and easy to manage as your customer count grows.

No fake user accounts. No tenant ID headers. No custom lookup tables in your middleware. Just a signed token that carries the truth.

Create a free Kinde account today and start building your multi-tenant M2M auth layer.

Top comments (0)