JIT provisioning: auto-creating users on first SSO login

Enterprise customers expect that when they add a new employee to their IdP (Okta, Azure AD, Google Workspace), that employee can log in to your application without a separate invitation step. Just-in-time provisioning fulfills this expectation: the first time a user authenticates via SSO, your application creates their account automatically from the attributes in the SAML assertion or OIDC token. No manual invite, no pre-provisioning, no admin action required.

SAML attribute mapping

A SAML assertion contains attributes the IdP sends about the authenticating user. The standard NameID element typically carries the user's email address or a stable identifier. Additional attributes carry the profile data and group memberships your application needs. The attribute names vary by IdP — Okta, Azure AD, and Google all use different attribute names for the same concepts, so your JIT provisioning code needs a configurable mapping.

// Per-org SAML attribute mapping configuration
interface SamlAttributeMapping {
  email: string;         // e.g. 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
  firstName: string;     // e.g. 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'
  lastName: string;
  groups: string;        // attribute name that carries group memberships
  role?: string;         // optional: attribute name for application role
}

// Default mappings for common IdPs
const DEFAULT_MAPPINGS: Record = {
  okta: {
    email: 'email',
    firstName: 'firstName',
    lastName: 'lastName',
    groups: 'groups',
    role: 'appRole',
  },
  azure_ad: {
    email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
    firstName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname',
    lastName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname',
    groups: 'http://schemas.microsoft.com/ws/2008/06/identity/claims/groups',
    role: 'http://schemas.microsoft.com/ws/2008/06/identity/claims/role',
  },
  google: {
    email: 'email',
    firstName: 'firstName',
    lastName: 'lastName',
    groups: 'groups',
  },
};

// Extract user profile from SAML attributes
function extractUserProfile(attributes: Record<string, string[]>, mapping: SamlAttributeMapping) {
  const get = (key: string) => attributes[key]?.[0] ?? '';
  return {
    email: get(mapping.email),
    firstName: get(mapping.firstName),
    lastName: get(mapping.lastName),
    groups: attributes[mapping.groups] ?? [],
    role: mapping.role ? get(mapping.role) : undefined,
  };
}

JIT provisioning logic

async function handleSSOLogin(orgId: string, samlAttributes: Record<string, string[]>, nameId: string) {
  const org = await db.orgs.findById(orgId);
  const mapping = org.samlAttributeMapping ?? DEFAULT_MAPPINGS[org.idpType] ?? DEFAULT_MAPPINGS.okta;
  const profile = extractUserProfile(samlAttributes, mapping);

  if (!profile.email) {
    throw new Error('saml_missing_email_attribute');
  }

  // Find existing user by stable IdP identifier (nameId) or email
  let user = await db.users.findOne({
    OR: [
      { ssoNameId: nameId, orgId },
      { email: profile.email.toLowerCase() },
    ],
  });

  const isNewUser = !user;

  if (!user) {
    // JIT: create the user
    user = await db.users.create({
      email: profile.email.toLowerCase(),
      firstName: profile.firstName,
      lastName: profile.lastName,
      ssoNameId: nameId,
      orgId,
      createdVia: 'sso_jit',
    });

    // Assign initial role
    const role = await resolveRoleFromIdP(profile, org);
    await db.orgMembers.create({ userId: user.id, orgId, role });

    await eventBus.publish('user.first_login', {
      userId: user.id,
      orgId,
      email: user.email,
      source: 'sso_jit',
    });
  } else {
    // Re-provisioning: update profile from IdP on each login
    await reproisionUser(user, profile, org);
  }

  return { user, isNewUser };
}

Group sync

IdPs can push group memberships in the SAML assertion. This lets the IdP admin manage group membership in Okta or Azure AD and have those changes reflected in your application without manual role changes. Group sync on SSO login means each login re-evaluates the user's groups and updates their role accordingly.

async function syncGroupMemberships(userId: string, orgId: string, idpGroups: string[]) {
  const org = await db.orgs.findById(orgId);

  // org.groupRoleMapping maps IdP group names to application roles
  // e.g. { "Admins": "admin", "Developers": "developer", "Viewers": "member" }
  const groupRoleMapping = org.groupRoleMapping ?? {};

  // Determine the highest-privilege role the user qualifies for
  let assignedRole = org.defaultSSORole ?? 'member';

  const rolePriority = ['owner', 'admin', 'developer', 'member', 'viewer'];

  for (const group of idpGroups) {
    const mappedRole = groupRoleMapping[group];
    if (mappedRole && rolePriority.indexOf(mappedRole) < rolePriority.indexOf(assignedRole)) {
      assignedRole = mappedRole;
    }
  }

  // Update the membership record
  await db.orgMembers.upsert({
    userId,
    orgId,
    role: assignedRole,
    idpGroups: idpGroups,
    lastSyncedAt: new Date(),
  });
}

Re-provisioning on subsequent logins

JIT provisioning does not stop after the first login. Each SSO login is an opportunity to re-sync the user's profile from the IdP. Names change, email addresses can be updated by the IdP admin (though use the NameID, not email, as the stable identifier), and group memberships change when employees change teams.

async function reproisionUser(user: User, profile: UserProfile, org: Org) {
  const updates: Partial<User> = {};

  // Update name if changed in IdP
  if (profile.firstName && profile.firstName !== user.firstName) {
    updates.firstName = profile.firstName;
  }
  if (profile.lastName && profile.lastName !== user.lastName) {
    updates.lastName = profile.lastName;
  }

  if (Object.keys(updates).length > 0) {
    await db.users.update({ id: user.id }, {
      ...updates,
      lastReprovisioned: new Date(),
    });
  }

  // Re-sync group memberships
  if (profile.groups.length > 0) {
    await syncGroupMemberships(user.id, org.id, profile.groups);
  }
}
Do not update the user's email address from re-provisioning unless you have a specific reason to do so. Email addresses are used as identifiers in many systems — billing contacts, notification preferences, audit logs. Silently changing a user's email because an IdP admin made a typo can cause serious data integrity issues. Treat email updates from the IdP as a change-request that requires confirmation, not an automatic update.
← Back to blog Try Bastionary free →