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);
}
}