DEV Community

Sarad
Sarad

Posted on

Stop deprovisioning by hand: make your HRMS the source of truth for access

Somewhere in your company there is probably an active account belonging to someone who left months ago. Not because anyone was careless, but because offboarding is a manual checklist that runs across a dozen systems, and checklists run by humans miss things. The Slack account gets disabled, the email gets forwarded, and three weeks later someone notices the ex-employee still has a valid login to the analytics dashboard and a personal access token sitting in a CI pipeline.
We pour a lot of energy into onboarding automation, because onboarding is visible and a new hire who can't log in on day one complains loudly. Offboarding is the opposite. Nobody complains when a leaver keeps access too long, right up until it surfaces in a security review or, worse, an incident. The fix isn't a better checklist. It's taking the human out of the loop, and the cleanest way to do that is to let one system own the truth about who works here and have everything else react to it.

That one system should be your HRMS

It already holds the authoritative record of every joiner, mover, and leaver: start dates, role changes, termination dates, employment type. If a person isn't in the HRMS, they don't work here. If their status flips to terminated, their access should evaporate. The whole job is connecting that record to the systems that actually grant access, so a status change in one place fans out everywhere on its own.

The pattern, roughly

Think of it as three layers.

  1. Source of truth — your HRMS. Every lifecycle change originates or is recorded here.
  2. *The broker *— your identity provider (Okta, Microsoft Entra, and friends). It maps a person to their accounts and group memberships, and it's already what most of your SaaS apps trust for SSO.
  3. Downstream systems — everything the IdP can't reach directly: the legacy app with its o wn user table, the CI tokens, the raw database grants.

The flow you want is HRMS event, then your service, then an IdP action plus any custom cleanup. Someone is hired, so you create their IdP user and assign groups from their role and department. Someone changes teams, so you reconcile their group membership. Someone leaves, so you suspend the IdP user, kill their live sessions, and trigger cleanup for anything sitting outside the IdP.

Getting the events out of the HRMS

Two ways, and you'll probably use both.
Webhooks are the nice path. The HRMS POSTs to your endpoint the moment something changes, so revocation happens in near real time, which is exactly what a termination needs. Polling is the fallback: hit the API on a schedule, diff against what you saw last time, act on the deltas. Polling is easier to reason about and it survives a dropped webhook, but a nightly sync means a leaver can hold access for most of a day, which isn't acceptable for privileged roles. The setup that holds up in practice is webhooks for the urgent events and a periodic full sync running underneath as a safety net.

A webhook handler, sketched out

Here's the shape of a handler in Node and TypeScript. Treat the payload fields as illustrative and map them to whatever your HRMS actually sends.

import express from "express";
import crypto from "crypto";

const app = express();
app.use(express.json({ verify: rawBodySaver }));

// keep the raw body around so we can verify the signature
function rawBodySaver(req, _res, buf) {
(req as any).rawBody = buf;
}

function verifySignature(req): boolean {
const got = Buffer.from(req.header("X-Hrms-Signature") ?? "", "utf8");
const expected = Buffer.from(
crypto
.createHmac("sha256", process.env.HRMS_WEBHOOK_SECRET!)
.update((req as any).rawBody)
.digest("hex"),
"utf8"
);
// length check first — timingSafeEqual throws on mismatched lengths
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
}

app.post("/webhooks/hrms", async (req, res) => {
if (!verifySignature(req)) return res.status(401).send("bad signature");

const { event, employee } = req.body;

// acknowledge fast, then do the slow work — senders retry if you stall
res.status(202).send("ok");

try {
switch (event) {
case "employee.onboarded":
await provision(employee);
break;
case "employee.role_changed":
await reconcileGroups(employee);
break;
case "employee.terminated":
await deprovision(employee);
break;
default:
// ignore everything else
}
} catch (err) {
// log it and let your queue retry — never swallow this silently
console.error(failed handling ${event} for ${employee?.id}, err);
}
});

And the part that matters most, the leaver path:

async function deprovision(employee) {
const userId = await idp.findUserByEmail(employee.workEmail);
if (!userId) return; // already gone, so this is a no-op

// 1. suspend first, so no new logins can succeed
await idp.suspendUser(userId); // e.g. POST /users/{id}/lifecycle/suspend
// 2. kill whatever is already in flight
await idp.revokeSessions(userId); // e.g. DELETE /users/{id}/sessions
await idp.revokeTokens(userId);
// 3. clean up the things your IdP doesn't manage
await revokeDatabaseGrants(employee.workEmail);
await rotateSharedSecretsTouchedBy(employee.id);

await audit.log("deprovisioned", { employeeId: employee.id, at: new Date() });
}

If you're on Okta or Entra, most of idp.* is a thin wrapper over their lifecycle and session APIs. The point of the wrapper is that your handler stays readable and you can swap providers without rewriting the logic.

The parts that bite

A few things you tend to learn the hard way:

  • Make every action idempotent. Webhooks get redelivered, syncs overlap, and you will process the same termination twice. Suspending an already-suspended user should be a no-op, not a 409 that crashes your handler.
  • Acknowledge before you act. Return 202 and push the job onto a queue. Account cleanup is slow, and if you block while doing it, the sender times out and retries, and now you're racing yourself.
  • Suspend before you delete. Suspension is reversible and buys a window for the "wait, they're actually transferring, not leaving" correction. Hard-delete on a delay, if you do it at all.
  • Respect the edge cases the HRMS encodes differently. Contractors, interns, people on long leave, and the rehire who reappears two months later on the same email. Key your logic off employment status, not a naive "exists or doesn't."
  • Keep an audit trail. When a review asks how you know someone lost access the day they left, the answer should be a log line with a timestamp, not a shrug.

Which HRMS, and what to look for

None of this works if your HRMS is a closed box. The requirements are unglamorous but specific. It has to expose lifecycle data through an API, ideally with webhooks, and it should plug into the identity provider your apps already trust so it can hand off the actual access decisions rather than trying to be your IdP as well. Zimyo, for instance, exposes an API and connects with Okta, Microsoft Entra, and OneLogin, which is the combination you need for it to sit at the top of this chain as the source of truth while your IdP does the provisioning.
If you're still choosing a platform and this kind of integration is a hard requirement rather than a nice-to-have, compare how each option actually handles API access and SSO before you commit. This roundup of HRMS platforms is a reasonable place to start narrowing the list.

Wrapping up

The goal isn't a perfectly automated org chart. It's that the moment HR marks someone as gone, every system agrees, without a ticket or anyone remembering a step. Automate onboarding and you save new hires a frustrating first day. Automate offboarding and you quietly close a security hole most companies don't realize they've been carrying around.
If you've built something like this, I'd be curious how you handle the rehire-on-the-same-email case — it's the one that always seems to slip through.

Top comments (0)