DEV Community

Cover image for How to Configure SAML Connections Programmatically With Kinde's Management API
Shola Jegede
Shola Jegede Subscriber

Posted on

How to Configure SAML Connections Programmatically With Kinde's Management API

You have forty enterprise customers, and every one of them needs its own SAML connection. You are not going to click through a dashboard forty times, and you definitely are not going to do it again every time one of them rotates a certificate. This is how you make the connection setup part of your onboarding flow instead of a recurring support ticket.

Configuring a single SAML connection by hand is fine. You open the Kinde dashboard, paste in a metadata URL, pick a Name ID format, save, and move on. The trouble starts when SAML stops being a one-off and becomes a repeatable step in how you onboard enterprise customers. Each customer's IT team sends slightly different details, a different Name ID format here, a stricter signing algorithm there, a binding their IdP happens to require, and every one of those becomes a manual configuration session that someone on your team has to run and then babysit.

At that point the dashboard is the wrong tool, and Kinde's Management API is the right one. Instead of a human entering values into a form, your onboarding flow makes an authenticated API call, the connection comes into existence in seconds, and your team is involved only when something genuinely needs a human. This article walks through how to build that, from authenticating to the API through to a provisioning service that creates, updates, and tears down SAML connections as customers come and go.

What you will learn

  • Why manual SAML setup quietly breaks once you have more than a handful of enterprise customers
  • How to authenticate to the Kinde Management API with a machine-to-machine application
  • How to create a SAML connection in a single API call, and what each of the key fields controls
  • How to map the details a customer's IT team sends you onto the right field values
  • When to update a connection in place versus replace it entirely, and the trap that sits between those two
  • How to use Kinde's strict validation to fail fast inside a provisioning pipeline rather than discovering a broken connection in production
  • How to wire all of this into an onboarding service that provisions, updates, and deprovisions connections automatically

When clicking through the dashboard stops scaling

The dashboard works beautifully for your first enterprise customer and remains tolerable for your second and third. The failure is gradual, which is part of why teams do not see it coming. Each new customer adds a configuration session, each session is a context switch for whoever runs it, and each connection becomes a thing that can drift, break, or need updating later. By the time you are at a couple of dozen customers, a meaningful slice of someone's week is spent inside the Kinde SAML form, and the work only grows from there.

There is a second cost that is easy to miss. When connection setup lives in a human's hands, it cannot be part of your product's onboarding flow. The customer signs the contract, and then they wait for your team to schedule the SAML work. Compare that to the version where your backend provisions the connection the moment the customer's plan upgrades to enterprise, hands their IT admin the ACS URL in the welcome email, and is ready before the admin has finished reading it. The difference between those two experiences is the difference between SSO as a support burden and SSO as a feature that sells itself.

The Management API is what moves you from the first world to the second. Everything below assumes that destination, a system where SAML connections are provisioned by code as a natural consequence of a customer becoming an enterprise customer.

Top lane, labelled MANUAL: a stick-figure customer signs a contract, then a long dotted

How the pieces fit together

Before any code, it helps to hold the whole shape in your head, because the individual API calls make far more sense once you can see where they sit.

Your application's onboarding flow is the trigger. When a customer crosses into enterprise territory, whether that is a plan upgrade, a sales handoff, or a manual flag your team flips, that event calls into a small provisioning service you own. That service authenticates to the Kinde Management API using a machine-to-machine application, creates a SAML connection scoped to the customer's Kinde organization, and returns the details the customer's IT admin needs to finish their side. Later, the same service handles changes, a certificate rotation becomes an update, a switch to a different identity provider becomes a replacement, and a customer leaving becomes a deletion.

In Kinde's model, each enterprise customer maps to a Kinde organization, and the SAML connection is attached to that organization. That mapping is what keeps tenants isolated, since one customer's Okta setup has no bearing on another's Entra configuration, and it is also the key your provisioning service uses to know which connection belongs to whom.

Provisioning architecture, a left-to-right sequence that doubles as the article's table of contents. Box 1:

Step 1: Authenticate to the Management API

Every call you make is authenticated with a short-lived access token, and you get that token from a machine-to-machine application. In the Kinde dashboard you create an M2M application under Settings then Applications, choosing Machine to Machine as the type, and Kinde issues it a client ID and client secret. You then authorize that application for the Management API and grant it only the scopes it needs, which for our purposes are the connection management scopes.

The Kinde

With the application authorized, your service exchanges its client credentials for an access token by calling the token endpoint. This is the same client credentials flow you would use for any backend-to-backend Kinde call.

// Exchange M2M client credentials for a Management API access token.
async function getManagementToken(): Promise<string> {
  const res = await fetch(`https://${process.env.KINDE_DOMAIN}/oauth2/token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "client_credentials",
      client_id: process.env.KINDE_M2M_CLIENT_ID!,
      client_secret: process.env.KINDE_M2M_CLIENT_SECRET!,
      // The Management API audience for your Kinde domain.
      audience: `https://${process.env.KINDE_DOMAIN}/api`,
    }),
  });

  if (!res.ok) {
    throw new Error(`Token request failed: ${res.status}`);
  }

  const { access_token } = await res.json();
  return access_token;
}
Enter fullscreen mode Exit fullscreen mode

The token is valid for a limited window, so a real provisioning service caches it and refreshes only when it is close to expiry rather than fetching a fresh one on every call.

Step 2: Create a SAML connection in one call

With a token in hand, creating a connection is a single authenticated POST to /api/v1/connections. The body carries a name, the strategy, and the SAML options that describe how Kinde and the customer's identity provider will understand each other. Kinde namespaces its strategy values, so a custom SAML connection uses saml:custom, sitting alongside built-in strategies like email:password and provider-specific ones like saml:google.

async function createSamlConnection(token: string, input: { name: string }) {
  const res = await fetch(`https://${process.env.KINDE_DOMAIN}/api/v1/connections`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: input.name,
      strategy: "saml:custom",
    }),
  });

  if (!res.ok) {
    const error = await res.json();
    throw new Error(`Create failed (${res.status}): ${JSON.stringify(error)}`);
  }

  // 201 → { code: "CONNECTION_CREATED", message: "...", connection: { id: "conn_..." } }
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

A minimal create needs only name and strategy, and Kinde returns 201 with the new connection.id. You then add the SAML specifics through the connection's options object, either on this create call or in a follow-up update once you have the customer's details. The fields that live under options are covered next.

A successful call returns the new connection's identifier in connection.id, and you will want to store that against the customer's record, because it is the handle you use for every later update or deletion. After the call succeeds, the connection exists in Kinde and you can confirm it in the dashboard exactly as if a human had created it.

The newly created SAML connection as it appears in the Kinde dashboard's enterprise connection list, sitting on the customer's organization

Step 3: Map what the customer sends you onto the right fields

Mapping the customer's details onto the four fields separates a script that works once from a provisioning system that holds up across dozens of different identity providers. No reference page walks you through it, because it is about judgement rather than syntax.

When a customer's IT team hands you their SAML details, they are giving you raw material that you have to translate into the four fields. Knowing which value goes where, and why each one matters, is what lets your service handle Okta, Entra, Google Workspace, and the long tail of less common providers without a human in the loop.

Field What it controls How to decide its value
idp_metadata_url The public URL Kinde reads to learn the IdP's endpoints and signing certificate Comes straight from the customer. Note that some providers, Google among them, will not host this at a stable URL, so the customer may give you a self-hosted link instead
name_id_format How the user's unique identifier is represented in the assertion Dictated by what the customer's IdP supports and is configured to send. Confirm it with their admin rather than assuming, since a mismatch here breaks sign-in
saml_user_id_key_attr Which attribute in the assertion Kinde treats as the user identifier Set this when the IdP carries the user ID in a non-standard attribute. Getting it right is what keeps a returning user mapped to the same Kinde identity instead of spawning duplicates
sign_request_algorithm The algorithm used to sign the SAML request Use rsa-sha256, the modern secure choice. Only fall back to rsa-sha1 when a particular IdP still requires it, and treat that as a temporary accommodation
protocol_binding The transport used to send the request to the IdP Use HTTP-POST for signed requests and larger payloads, which is the common case, and switch to the redirect binding only when the IdP supports nothing else

Secure defaults matter because your provisioning service is making these choices for every customer at once. If your default signing algorithm is the strong one and you only step down when an IdP forces it, then your whole customer base is secure by default and the exceptions are visible and deliberate. If you let each customer's IT team push you toward whatever is easiest on their side, you end up with a fleet of connections whose security posture you cannot reason about.

Step 4: Update in place, or replace entirely

Connections are not static. Certificates rotate, customers migrate from one identity provider to another, and occasionally a field was simply set wrong and needs correcting. Kinde handles all of these through a single PATCH /api/v1/connections/{id} call, and the distinction that matters is how much of the options object you send.

A partial update changes specific fields while leaving the rest of the connection as it is. This is what you reach for in the common cases. A customer's IdP rotates its signing certificate and republishes its metadata, and you patch the metadata URL. A customer asks to move from the older signing algorithm to the modern one, and you patch that single field. These updates are surgical, and they are the right default. A successful call returns {"code": "CONNECTION_UPDATED"}.

A full replacement sends the entire options object when the change is wholesale rather than incremental, the clearest example being a customer migrating from, say, Okta to Entra ID, where almost everything about the connection is now different. You use the same PATCH endpoint, but you send the complete new configuration rather than a single field.

A small decision flow. Start node:

One trap bites people who treat a wholesale change carelessly. When you send an options object, treat it as the new state of those fields, so read the current connection first, merge your changes onto what is already there, and send the complete object. If you intend a full replacement and forget to include the signing algorithm you had carefully set, you risk losing it. Treat a partial patch as "change exactly these fields" and a full replacement as "here is the complete new configuration", and the trap disappears.

Step 5: Let validation fail fast for you

Kinde recently changed how it handles bad values. Previously, an invalid enum value might be accepted silently and quietly corrected at runtime, which is the kind of behaviour that hides a misconfiguration until a user cannot sign in. Now an invalid value for one of these fields returns a clear 400 error immediately.

For a provisioning service, this is a gift rather than an inconvenience. It means a malformed configuration fails at the moment you try to create it, in your pipeline, where you can catch it and surface it, instead of failing days later in a customer's sign-in flow where it becomes a support escalation. Let the 400 propagate into your onboarding system as a visible, actionable error.

try {
  const connection = await createSamlConnection(token, input);
  await saveConnectionId(input.organizationCode, connection.id);
  return { ok: true, connectionId: connection.id };
} catch (err) {
  // A 400 here means a bad field value. Surface it to your onboarding UI
  // so it is fixed now, not discovered by the customer in production.
  return { ok: false, reason: (err as Error).message };
}
Enter fullscreen mode Exit fullscreen mode

Your provisioning service should treat connection creation the way you treat any other deploy. It either succeeds cleanly or it tells you precisely what was wrong, and a customer never becomes the person who discovers the mistake.

A terminal view showing two responses stacked: a successful create returning the new connection body ( raw `code: CONNECTION_CREATED` endraw  with the  raw `connection.id` endraw ), and a deliberately broken create with an invalid  raw `sign_request_algorithm` endraw  value returning a 400 with  raw `code: INVALID_SIGN_REQUEST_ALGORITHM` endraw

Step 6: The full lifecycle

Putting the pieces together, a provisioning service is a small, well-defined surface that your onboarding flow calls into. It provisions a connection when a customer becomes enterprise, updates it as the customer's setup changes over time, and removes it when the customer leaves so you are not carrying dead connections.

class EnterpriseSsoProvisioner {
  // Called when a customer upgrades to an enterprise plan.
  async provision(idp: { name: string; idpMetadataUrl: string; signRequestAlgorithm: "rsa-sha256" | "rsa-sha1" }) {
    const token = await getManagementToken();
    const { connection } = await createSamlConnection(token, idp);
    await saveConnectionId(connection.id);
    return this.handoffDetails(connection.id); // ACS URL + Entity ID for the customer's admin
  }

  // Certificate rotation, algorithm change, fixing a single field.
  // PATCH /api/v1/connections/{id} with only the fields that moved.
  async patch(connectionId: string, changes: Partial<SamlOptions>) {
    const token = await getManagementToken();
    return patchConnection(token, connectionId, { options: changes });
  }

  // Customer migrates to a different IdP: send the complete new options object.
  async replace(connectionId: string, fullOptions: SamlOptions) {
    const token = await getManagementToken();
    return patchConnection(token, connectionId, { options: fullOptions });
  }

  // Customer churns or downgrades.
  // DELETE /api/v1/connections/{id}
  async deprovision(connectionId: string) {
    const token = await getManagementToken();
    await deleteConnection(token, connectionId);
  }
}
Enter fullscreen mode Exit fullscreen mode

The shape here is the real deliverable. Your sales and onboarding flows do not know or care about SAML internals. They call provision when a deal closes and deprovision when it ends, and the messy reality of metadata URLs and binding choices lives entirely inside this one service.

Connection lifecycle as a simple state path. PROVISIONED (created via POST) loops back on itself for certificate rotation or algorithm change (PATCH a few fields) and for IdP migration (PATCH the full options object), then moves to DEPROVISIONED (DELETE). Label each transition with the real-world event that triggers it. This gives the reader a mental model of a connection's whole life rather than four disconnected API calls.

Step 7: Test the whole thing without a customer

You do not need a real enterprise customer, or even a real identity provider, to build and verify this. A throwaway Kinde account with a test organization is enough to exercise every call, and a few curl commands let you watch the real request and response shapes before you wire any of it into your product.

Run the create call against your test organization and confirm the connection appears in the dashboard. Send a deliberately invalid enum value and confirm you get the 400, so you know your error handling works. Update a single field and confirm only that field changed. Then delete it and confirm it is gone. Once those four behave the way you expect, you have validated the entire surface your provisioning service depends on, and you can build the rest with confidence.

Wrapping up

The forty-customer problem from the opening is, underneath, a question of whether SAML setup is something a person does or something your system does. Hand-configuring connections is fine until it is not, and the point where it stops scaling arrives sooner than most teams expect, usually right as enterprise deals start closing in earnest. Moving that work into the Management API turns a recurring manual chore into a provisioning service that runs as a natural part of onboarding, fails loudly and early when a value is wrong, and handles the full life of a connection from creation through certificate rotations to eventual teardown.

You authenticate once with a machine-to-machine application, create connections with a single call, map each customer's details onto the four fields that matter, choose update or replace depending on how much is changing, and lean on Kinde's strict validation to keep broken configurations out of production. The result is an onboarding flow where an enterprise customer's SSO is ready before their IT admin has finished reading the welcome email.

Kinde is free for up to 10,500 monthly active users, with no credit card required to start. You can create an account, set up a machine-to-machine application, and try these calls against a test organization at kinde.com, and the full Management API reference documents every field and operation in detail.

I write about identity, billing, and the infrastructure behind multi-tenant and AI products. If provisioning systems like this are your world, follow along for more.

Top comments (0)