sdxc

Type to search, or start from one of these:

[ Identity & security ]

Enterprise sign-on with SAML and SCIM

Let a customer's directory sign its people in over SAML 2.0 and provision their users and groups over SCIM 2.0.

Last updated 2026-09-29

The first large customer asks two questions: can our people sign in through our own identity provider, and will your app know when someone leaves? The first is SAML, where their directory posts a signed assertion to your app. The second is SCIM, where their directory calls your API to create, update and deactivate the people and groups it manages.

@sdxc/saml is the service provider half of SAML 2.0: it builds the authentication request, verifies the signed response, and reads and writes the metadata the two sides exchange. @sdxc/scim is the standard's half of a SCIM 2.0 endpoint: resources, filters, PATCH and the discovery documents. Routing, storage and authentication stay in your app, and this guide wires both into a Remix v3 router.

npm add @sdxc/saml @sdxc/scim @sdxc/result @sdxc/duration @sdxc/crypto \
	@sdxc/http @sdxc/uuid remix

The routes

Every customer gets its own connection, named by a slug. Each connection is a separate service provider to the customer's directory, with its own entity id, so its URLs carry the slug. SCIM lives under /scim/v2, where the RFC's clients expect it.

routes/web.ts
import { get, patch, post, route } from "remix/routes";

export default route({
	home: get("/"),
	sso: {
		metadata: get("/sso/:slug/metadata"),
		start: get("/sso/:slug"),
		acs: post("/sso/:slug/acs"),
	},
	scim: {
		discovery: {
			config: get("/scim/v2/ServiceProviderConfig"),
			resourceTypes: get("/scim/v2/ResourceTypes"),
			schemas: get("/scim/v2/Schemas"),
		},
		users: {
			list: get("/scim/v2/Users"),
			create: post("/scim/v2/Users"),
			patch: patch("/scim/v2/Users/:id"),
		},
		groups: { create: post("/scim/v2/Groups") },
	},
});

Derive the absolute identifiers from the route table, so the URL your metadata publishes and the URL an assertion is checked against are one value:

app/sso/service-provider.ts
import routes from "~/routes/web";

export function serviceProvider(url: URL, slug: string) {
	return {
		entityId: new URL(routes.sso.metadata.href({ slug }), url).href,
		acs: new URL(routes.sso.acs.href({ slug }), url).href,
	};
}

Configure a connection from the provider's metadata

An administrator on the customer's side hands you their identity provider's metadata, a URL or an XML file. parseIdPMetadata reads the entity id, the sign-on endpoints and the signing certificates out of it, each certificate already parsed:

app/sso/provider-metadata.ts
import { isFailure, success } from "@sdxc/result";
import * as SAML from "@sdxc/saml";

const REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";

export async function readProviderMetadata(xml: string) {
	let metadata = await SAML.parseIdPMetadata(xml);
	if (isFailure(metadata)) return metadata;

	let endpoint = metadata.data.singleSignOn.find((it) => it.binding === REDIRECT);
	return success({
		entityId: metadata.data.entityId,
		ssoUrl: endpoint?.location ?? null,
		certificates: metadata.data.signing.map((certificate) => ({
			fingerprint: certificate.fingerprint,
			notAfter: certificate.notAfter,
			pem: certificate.toPem(),
		})),
	});
}

Store every certificate, keyed by fingerprint. Providers rotate on their own schedule, and a response verifies when it matches any certificate you pass, so keeping the old and new ones side by side makes a rotation a non-event. notAfter is what a scheduled job reads to warn an administrator before an expiry stops sign-ins.

Publish your own metadata

The other direction: the customer's administrator needs your entity id, your ACS URL and your certificate. Serve them as a document rather than asking anyone to copy four fields.

app/http/controllers/sso/metadata.ts
import { text, xml } from "@sdxc/http/response";
import { isFailure } from "@sdxc/result";
import * as SAML from "@sdxc/saml";
import { env } from "cloudflare:workers";
import * as s from "remix/data-schema";
import { createAction } from "remix/router";

import { serviceProvider } from "~/app/sso/service-provider";
import routes from "~/routes/web";

export default createAction(routes.sso.metadata, async (ctx) => {
	let { slug } = s.parse(s.object({ slug: s.string() }), ctx.params);
	let certificate = await SAML.Certificate.parse(env.SAML_SP_CERTIFICATE);
	if (isFailure(certificate)) return text("Not configured", { status: 503 });

	let sp = serviceProvider(ctx.url, slug);
	let document = SAML.buildServiceProviderMetadata({
		entityId: sp.entityId,
		assertionConsumerService: sp.acs,
		certificate: certificate.data,
		nameIdFormat: null,
		wantAssertionsSigned: true,
		authnRequestsSigned: true,
		validUntil: null,
	});
	if (isFailure(document)) return text("Not configured", { status: 503 });

	return xml(document.data);
});

None of the options has a default, so nameIdFormat: null (let the provider choose) and validUntil: null are decisions written down. Generate the certificate once, from an RSA key pair you keep as a secret, with SAML.Certificate.selfSigned({ keys, commonName, notBefore, notAfter }) and toPem().

Start a sign-in

The login form asks for the work email, finds the connection for its domain, and sends the browser to routes.sso.start. createAuthnRequest builds the request and signs it with your private key:

app/http/controllers/sso/start.ts
import { redirect, text } from "@sdxc/http/response";
import { isFailure } from "@sdxc/result";
import * as SAML from "@sdxc/saml";
import { generateUUID } from "@sdxc/uuid";
import { env } from "cloudflare:workers";
import * as s from "remix/data-schema";
import { createAction } from "remix/router";

import { Connections } from "~/app/repositories/connections";
import { signingKey } from "~/app/sso/keys";
import { serviceProvider } from "~/app/sso/service-provider";
import routes from "~/routes/web";

export default createAction(routes.sso.start, async (ctx) => {
	let { slug } = s.parse(s.object({ slug: s.string() }), ctx.params);
	let connection = await Connections.find(ctx.db, slug);
	if (connection === null) return text("Unknown connection", { status: 404 });

	let sp = serviceProvider(ctx.url, slug);
	let relayState = generateUUID();
	let request = await SAML.createAuthnRequest({
		binding: "redirect",
		destination: connection.ssoUrl,
		issuer: sp.entityId,
		assertionConsumerService: sp.acs,
		nameIdFormat: null,
		forceAuthn: false,
		relayState,
		signingKey: await signingKey(),
		now: new Date(),
	});
	if (isFailure(request) || request.data.url === null) {
		return text("Sign-in unavailable", { status: 503 });
	}

	let pending = { requestId: request.data.id, slug };
	await env.SSO.put(relayState, JSON.stringify(pending), { expirationTtl: 600 });
	return redirect(request.data.url);
});

Remember request.data.id: the response has to answer with it, and passing it back as inResponseTo binds the assertion to the browser that asked for it. It is stored under the RelayState rather than in the session, because the response arrives as a cross-site POST and a SameSite=Lax session cookie is not sent with it. The RelayState comes back in the form beside the response. signingKey imports your PKCS #8 private key with crypto.subtle.importKey for RSASSA-PKCS1-v1_5 with SHA-256.

Verify the assertion

The assertion consumer service is the one route where a stranger's XML decides who is signed in, so everything about it goes through one call. verifyResponse refuses a document type declaration, requires exactly one signature, checks the signature covers the assertion it returns, and checks Destination, Audience, Recipient, the validity window, InResponseTo and the replay store. Every option is required, so a forgotten audience does not compile.

app/http/controllers/sso/acs.tsx
import { Base64 } from "@sdxc/crypto";
import { redirect } from "@sdxc/http/response";
import { isFailure } from "@sdxc/result";
import * as SAML from "@sdxc/saml";
import { env } from "cloudflare:workers";
import { createAction } from "remix/router";

import { startSession } from "~/app/http/session";
import { Users } from "~/app/repositories/users";
import { findPending } from "~/app/sso/pending";
import { replayStore } from "~/app/sso/replay-store";
import { serviceProvider } from "~/app/sso/service-provider";
import { LoginPage } from "~/resources/views/login";
import routes from "~/routes/web";

export default createAction(routes.sso.acs, async (ctx) => {
	let refuse = () =>
		ctx.render(<LoginPage error="Sign-in failed." />, { status: 403 });
	let pending = await findPending(ctx);
	if (pending === null) return refuse();
	await env.SSO.delete(pending.relayState);

	let xml = Base64.decode(pending.samlResponse);
	if (isFailure(xml)) return refuse();

	let sp = serviceProvider(ctx.url, pending.connection.slug);
	let verified = await SAML.verifyResponse(new TextDecoder().decode(xml.data), {
		certificates: pending.connection.certificates,
		audience: sp.entityId,
		destination: sp.acs,
		recipient: sp.acs,
		inResponseTo: pending.requestId,
		decryptionKey: null,
		replay: replayStore(ctx.db, pending.connection.id),
		clock: { now: new Date(), skew: "60 seconds" },
	});
	if (isFailure(verified)) {
		ctx.log.warn("sso.rejected", { kind: verified.error.name });
		return refuse();
	}

	let user = await Users.upsertFromSso(ctx.db, {
		connectionId: pending.connection.id,
		subject: verified.data.nameId?.value ?? null,
		email: verified.data.attribute("email"),
	});
	await startSession(ctx, user.id);
	return redirect(routes.home.href(), { status: redirect.Status.SeeOther });
});

findPending is yours: it validates the posted SAMLResponse and RelayState fields, reads the stored request under the RelayState from the SSO namespace, and loads the connection with its certificates parsed by SAML.Certificate.parse. The handler deletes that entry before verifying anything, so each request answers at most one response. A response with no RelayState is a sign-in the provider started; refusing it, as here, is the safe default, and inResponseTo: null is how you accept one once you have decided to.

The failure's name is safe to log: no SAMLError carries key material or anything the document supplied beyond a fixed identifier. UnsupportedFeatureError names the setting the customer has to change, such as SHA-1 signatures. Key the account on the NameID for this connection rather than on the email, which the directory may change. For encrypted assertions, pass your private key imported for RSA-OAEP as decryptionKey, once with SHA-1 and once with SHA-256 in an array, since providers use both.

Two more things belong in front of this route. It receives a cross-site POST, so exempt it from Remix's cop() protection the way you would a webhook. And canonicalizing the document is CPU work over the whole assertion, so cap the size of the body before it gets here.

Remember assertions for as long as they are valid

The replay store is two methods. verifyResponse calls remember only once every other check has passed, with a TTL covering the rest of the assertion's own window, so a forged document never fills the table and a stored row outlives exactly the period a captured one could be replayed.

app/sso/replay-store.ts
import type { ReplayStore } from "@sdxc/saml";
import type { Database } from "remix/data-table";

import { toMs } from "@sdxc/duration";
import { column as c, table } from "remix/data-table";

export const assertionIds = table({
	name: "saml_assertion_ids",
	primaryKey: ["id"],
	columns: { id: c.text(), expires_at: c.integer() },
});

export function replayStore(db: Database, connectionId: string): ReplayStore {
	return {
		async seen(id) {
			return (
				(await db.find(assertionIds, { id: `${connectionId}:${id}` })) !==
				null
			);
		},
		async remember(id, ttl) {
			let row = {
				id: `${connectionId}:${id}`,
				expires_at: Date.now() + toMs(ttl),
			};
			await db.create(assertionIds, row);
		},
	};
}

The connection id is part of the key because the provider chooses assertion ids, and two providers must not collide. A D1 table is consistent where KV is eventually consistent, which matters for a check whose whole job is to catch the second use. A store that throws surfaces as ReplayStoreError: no verdict was reached, so let the person retry.

Authenticate the directory

SCIM clients authenticate with a bearer token you issue per directory. Store only its SHA-256 hash, and look the directory up by it:

app/http/middleware/scim-auth.ts
import type { Middleware } from "remix/router";

import { Hex, sha256 } from "@sdxc/crypto";
import { isFailure } from "@sdxc/result";
import { errorResponse, ScimError } from "@sdxc/scim";
import { createContextKey } from "remix/router";

import { Directories } from "~/app/repositories/directories";

export const DirectoryId = createContextKey<string>();

declare module "remix/router" {
	interface RequestContext {
		directoryId: string;
	}
}

export const scimAuth: Middleware = async (ctx, next) => {
	let header = ctx.request.headers.get("Authorization") ?? "";
	let token = header.startsWith("Bearer ") ? header.slice(7) : "";
	let digest = await sha256(token);
	let directory = isFailure(digest)
		? null
		: await Directories.findByTokenHash(ctx.db, Hex.encode(digest.data));
	if (token === "" || directory === null) {
		return errorResponse(new ScimError(401, "The token matches no directory."));
	}

	ctx.set(DirectoryId, directory.id, { property: "directoryId" });
	return next();
};

errorResponse writes the SCIM error document of RFC 7644 with the application/scim+json type, which is what a directory's client parses. Every failure @sdxc/scim reports is a ScimError that errorResponse accepts, so all the handlers below answer the same way.

Describe what you serve

One set of definitions says which attributes your app models. The /Schemas document advertises it, and filters, PATCH and projection evaluate against it, so what the directory is told and what the server does cannot disagree.

app/scim/definitions.ts
import type { Discovery } from "@sdxc/scim/discovery";

import { ENTERPRISE_USER_SCHEMA, GROUP_SCHEMA, USER_SCHEMA } from "@sdxc/scim";
import {
	ENTERPRISE_USER_DEFINITION,
	GROUP_DEFINITION,
	pickAttributes,
	USER_DEFINITION,
} from "@sdxc/scim/discovery";

export const MAX_PAGE_SIZE = 200;

export const USER_DEFINITIONS: Discovery.Definitions = {
	[USER_SCHEMA]: pickAttributes(USER_DEFINITION, [
		"userName",
		"name.givenName",
		"name.familyName",
		"active",
		"emails.value",
		"emails.primary",
	]),
	[ENTERPRISE_USER_SCHEMA]: pickAttributes(ENTERPRISE_USER_DEFINITION, [
		"department",
	]),
};

export const GROUP_DEFINITIONS: Discovery.Definitions = {
	[GROUP_SCHEMA]: pickAttributes(GROUP_DEFINITION, [
		"displayName",
		"members.value",
	]),
};

Put the core schema first: an unqualified name such as active resolves against the definitions in insertion order. The three discovery documents come from the same values:

app/http/controllers/scim/discovery.ts
import {
	ENTERPRISE_USER_SCHEMA,
	GROUP_SCHEMA,
	scimResponse,
	USER_SCHEMA,
} from "@sdxc/scim";
import { resourceTypes, schemas, serviceProviderConfig } from "@sdxc/scim/discovery";
import { createController } from "remix/router";

import {
	GROUP_DEFINITIONS,
	MAX_PAGE_SIZE,
	USER_DEFINITIONS,
} from "~/app/scim/definitions";
import routes from "~/routes/web";

const USER_TYPE = {
	id: "User",
	endpoint: "/Users",
	schema: USER_SCHEMA,
	extensions: [{ schema: ENTERPRISE_USER_SCHEMA, required: false }],
};
const GROUP_TYPE = { id: "Group", endpoint: "/Groups", schema: GROUP_SCHEMA };
const BEARER = {
	type: "oauthbearertoken",
	name: "Bearer",
	description: "Per directory.",
};

export default createController(routes.scim.discovery, {
	actions: {
		config: () =>
			scimResponse(
				serviceProviderConfig({
					patch: true,
					filter: { supported: true, maxResults: MAX_PAGE_SIZE },
					sort: false,
					etag: false,
					authenticationSchemes: [BEARER],
				}),
			),
		resourceTypes: () => scimResponse(resourceTypes([USER_TYPE, GROUP_TYPE])),
		schemas: () =>
			scimResponse(
				schemas([
					...Object.values(USER_DEFINITIONS),
					...Object.values(GROUP_DEFINITIONS),
				]),
			),
	},
});

These answer without scimAuth: a client reads them to negotiate before it presents a token.

Create and list users

readBody accepts application/scim+json, plain JSON or no content type, and parseUser reads the User, matching attribute names case-insensitively and typing the Enterprise User extension. userResource writes the wire form back.

app/http/controllers/scim/users.ts
import { isFailure } from "@sdxc/result";
import * as Scim from "@sdxc/scim";
import { createAction } from "remix/router";

import { scimAuth } from "~/app/http/middleware/scim-auth";
import { Users } from "~/app/repositories/users";
import { MAX_PAGE_SIZE, USER_DEFINITIONS } from "~/app/scim/definitions";
import { findUsers } from "~/app/scim/find-users";
import routes from "~/routes/web";

export const create = createAction(routes.scim.users.create, {
	middleware: [scimAuth],
	async handler(ctx) {
		let body = await Scim.readBody(ctx.request);
		if (isFailure(body)) return Scim.errorResponse(body.error);
		let user = Scim.parseUser(body.data);
		if (isFailure(user)) return Scim.errorResponse(user.error);

		let saved = await Users.provision(ctx.db, ctx.directoryId, user.data);
		return Scim.scimResponse(Scim.userResource(saved), { status: 201 });
	},
});

export const list = createAction(routes.scim.users.list, {
	middleware: [scimAuth],
	async handler(ctx) {
		let options = { maxCount: MAX_PAGE_SIZE, attributes: USER_DEFINITIONS };
		let query = Scim.parseListQuery(ctx.url, options);
		if (isFailure(query)) return Scim.errorResponse(query.error);

		let found = await findUsers(ctx.db, ctx.directoryId, query.data.filter);
		if (isFailure(found)) return Scim.errorResponse(found.error);

		let { startIndex, count } = query.data;
		let page = found.data.slice(startIndex - 1, startIndex - 1 + count);
		return Scim.listResponse(
			{ resources: page, totalResults: found.data.length, startIndex },
			(resource) => Scim.project(resource, query.data, USER_DEFINITIONS),
		);
	},
});

parseListQuery clamps startIndex and count to the ranges the RFC allows and caps count at maxCount. project applies the attributes and excludedAttributes a client asked for, and each attribute's returned rule. A directory's first call is usually filter=userName eq "ada@example.com", to find out whether a person already exists. The filter is where the two sub-packages meet:

app/scim/find-users.ts
import type { Filter } from "@sdxc/scim/filter";
import type { Database } from "remix/data-table";

import { isFailure, isSuccess, success } from "@sdxc/result";
import { userResource } from "@sdxc/scim";
import { filterToWhere } from "@sdxc/scim/data-table";
import { compileFilter } from "@sdxc/scim/filter";

import { Users } from "~/app/repositories/users";
import { USER_DEFINITIONS } from "~/app/scim/definitions";

const ALLOW = ["userName", "externalId", "emails.value"];
const COLUMNS = { userName: { column: "user_name", caseExact: false } };

export async function findUsers(
	db: Database,
	directoryId: string,
	filter: Filter.Expression | null,
) {
	let matches = filter
		? compileFilter(filter, { definitions: USER_DEFINITIONS, allow: ALLOW })
		: null;
	if (matches && isFailure(matches)) return matches;

	let where = filter ? filterToWhere(filter, COLUMNS) : null;
	let pushed = where && isSuccess(where) ? where.data : null;
	let rows = await Users.list(db, directoryId, pushed);

	let resources = rows.map((user) => userResource(user));
	if (matches && pushed === null) resources = resources.filter(matches.data);
	return success(resources);
}

compileFilter checks the filter against the definitions and the allow list first, so a path your store cannot answer is a 400 invalidFilter rather than a silent full list. Then filterToWhere tries to turn it into a remix/data-table predicate. It fails with UntranslatableFilterError for what SQL cannot express exactly, such as a value path like emails[type eq "work"] or a column you did not map, and the compiled predicate filters in memory instead. userName is caseExact: false in the RFC, so its column folds case through ilike, and userName eq "Ada@Example.com" finds ada@example.com either way.

Patch a user

Directories change people with PATCH, and they deprovision with it too: Okta and Entra ID usually send active: false rather than a DELETE. Apply the operations to the current wire representation, then store the result the way a replace would:

app/http/controllers/scim/patch-user.ts
import { isFailure } from "@sdxc/result";
import * as Scim from "@sdxc/scim";
import { applyPatch, parsePatch } from "@sdxc/scim/patch";
import * as s from "remix/data-schema";
import { createAction } from "remix/router";

import { scimAuth } from "~/app/http/middleware/scim-auth";
import { Users } from "~/app/repositories/users";
import { USER_DEFINITIONS } from "~/app/scim/definitions";
import routes from "~/routes/web";

export default createAction(routes.scim.users.patch, {
	middleware: [scimAuth],
	async handler(ctx) {
		let { id } = s.parse(s.object({ id: s.string() }), ctx.params);
		let current = await Users.find(ctx.db, ctx.directoryId, id);
		if (current === null)
			return Scim.errorResponse(new Scim.ScimError(404, "No such user."));

		let body = await Scim.readBody(ctx.request);
		if (isFailure(body)) return Scim.errorResponse(body.error);
		let operations = parsePatch(body.data);
		if (isFailure(operations)) return Scim.errorResponse(operations.error);

		let definitions = USER_DEFINITIONS;
		let patched = applyPatch(Scim.userResource(current), operations.data, {
			definitions,
		});
		if (isFailure(patched)) return Scim.errorResponse(patched.error);
		let next = Scim.parseUser(patched.data);
		if (isFailure(next)) return Scim.errorResponse(next.error);

		let saved = await Users.replace(ctx.db, ctx.directoryId, id, next.data);
		return Scim.scimResponse(Scim.userResource(saved));
	},
});

applyPatch applies every operation or none, and reports noTarget, mutability and invalidPath as the RFC names them. Where an attribute is a boolean, it reads Entra ID's "False" string as false. When Users.replace sees active turn false, end that person's sessions there: that is the reason the customer asked for SCIM.

Groups

Groups follow the same shape with parseGroup and groupResource. A member's value is the id your user resource answered with, and membership changes arrive as PATCH operations on members, applied with applyPatch against GROUP_DEFINITIONS.

app/http/controllers/scim/groups.ts
import { isFailure } from "@sdxc/result";
import * as Scim from "@sdxc/scim";
import { createAction } from "remix/router";

import { scimAuth } from "~/app/http/middleware/scim-auth";
import { Groups } from "~/app/repositories/groups";
import routes from "~/routes/web";

export const create = createAction(routes.scim.groups.create, {
	middleware: [scimAuth],
	async handler(ctx) {
		let body = await Scim.readBody(ctx.request);
		if (isFailure(body)) return Scim.errorResponse(body.error);
		let group = Scim.parseGroup(body.data);
		if (isFailure(group)) return Scim.errorResponse(group.error);

		let saved = await Groups.create(ctx.db, ctx.directoryId, group.data);
		return Scim.scimResponse(Scim.groupResource(saved), { status: 201 });
	},
});

Mount it

Map the routes in the composition root from Wire the router, next to the rest of your app:

bootstrap/enterprise.ts
import type { createRouter } from "remix/router";

import discovery from "~/app/http/controllers/scim/discovery";
import * as groups from "~/app/http/controllers/scim/groups";
import patchUser from "~/app/http/controllers/scim/patch-user";
import * as users from "~/app/http/controllers/scim/users";
import acs from "~/app/http/controllers/sso/acs";
import metadata from "~/app/http/controllers/sso/metadata";
import start from "~/app/http/controllers/sso/start";
import routes from "~/routes/web";

export function mapEnterprise(router: ReturnType<typeof createRouter>) {
	router.map(routes.sso.metadata, metadata);
	router.map(routes.sso.start, start);
	router.map(routes.sso.acs, acs);
	router.map(routes.scim.discovery, discovery);
	router.map(routes.scim.users.list, users.list);
	router.map(routes.scim.users.create, users.create);
	router.map(routes.scim.users.patch, patchUser);
	router.map(routes.scim.groups.create, groups.create);
}

Reading, replacing and deleting a user or a group are the same calls in a different order: parseUser or parseGroup for a PUT, and a 204 for a DELETE. Directories retry eagerly, so give the SCIM routes a rate limit of their own, keyed on the token's hash.

Where to go next