Search the documentation
Search the documentation...
SplitSecure documentation

On this page

Procedure · 9 steps

Build a service provider

Install the StepAuth SDK, write and sign approval requests, and verify the decisions that the hub delivers.

Updated

Before you start

Your application is the service provider. In this guide, you give it a signing identity, send an approval request, and run the action only after a verified approval. The example is Acme MDM, at acme.example.com, which asks for approval before it wipes a laptop.

You add two things with the StepAuth SDK:

  1. A send call before the sensitive action. It names the action, the person who started it, and the approver. It returns at once, with a pending state that your application stores.
  2. A callback endpoint that receives the decision later. The SDK verifies the decision against the pending state. For an approval, your code runs the action and returns the result.

The SDK signs and verifies every message, retries, and checks that each decision belongs to your request.

Install the SDK

In your application code

Add the SDK to your backend:

Shell
npm install @splitsecure/stepauth

The examples on this page use these imports.

TypeScript
import {
	createClient,
	email,
	entry,
	generateKeyset,
	isApproved,
	newRequestId,
	policyId,
	publicKeyset,
	SendFailedError,
	signingKeysetFromJSON,
	signingKeysetToJSON,
	type Config,
	type ExecutionResult,
	type HubMetadata
} from '@splitsecure/stepauth';

Establish a signing identity

In your application code

The keyset is the identity of your application. It holds an ed25519 key and a post-quantum ml-dsa-65 key, and both sign every message. Mint it on the first start, and store it. Every later start loads the stored copy.

Acme MDM keeps its keyset in its secret store, under the key stepauth_keyset. On the first start, it mints the keyset acme-mdm-1. Two instances can start at the same time. Acme MDM therefore asks its store to write the keyset only when the key is missing, and to return the stored value either way. The examples call that operation put-if-absent. Your store needs the same operation. Every instance then signs with the same keyset:

TypeScript
let stored = await store.get('stepauth_keyset');
if (!stored) {
	const minted = generateKeyset('acme-mdm-1');
	stored = await store.putIfAbsent('stepauth_keyset', JSON.stringify(signingKeysetToJSON(minted)));
}
const keyset = signingKeysetFromJSON(JSON.parse(stored));

The service provider metadata is the document that the organization administrator registers. It holds your entity, the public half of your keyset, and your default callback URL. The SDK exports the public half without the private keys.

Acme MDM exports its service provider metadata with the entity acme.example.com and the callback URL https://acme.example.com/stepauth/callback:

TypeScript
const appMetadata = {
	entity: 'acme.example.com',
	keysets: [publicKeyset(keyset)],
	callbackUrl: 'https://acme.example.com/stepauth/callback'
};

console.log(JSON.stringify(appMetadata, null, 2));

The Acme MDM document looks like this one. The hub ignores the $schema line, so the export does not need it.

JSON
{
	"$schema": "https://docs.splitsecure.com/schemas/stepauth/app/v1.json",
	"entity": "acme.example.com",
	"keysets": [
		{
			"keysetId": "acme-mdm-1",
			"keys": [
				{
					"idx": 0,
					"alg": "ed25519",
					"pub": "ZTGd3DIu0K045tM+3ghNKU8+Z8oKzRuP6R7750gHWsc="
				},
				{
					"idx": 1,
					"alg": "ml-dsa-65",
					"pub": "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"
				}
			]
		}
	],
	"callbackUrl": "https://acme.example.com/stepauth/callback"
}

Send the document to the organization administrator, who registers it in Register the service provider .

Rotate the keyset

The hub cannot update a registration. If you start to sign with a new keyset before the administrator registers it, the hub rejects every request with invalid_signature.

To replace the keyset without rejected requests:

  1. Mint a keyset with a new keysetId, for example acme-mdm-2. Keep the old keyset in use.
  2. Export service provider metadata that holds both public keysets. The administrator replaces the registration with it. The hub finds the keyset of each request by its keysetId.
  3. Sign with the new keyset only.
  4. Export service provider metadata with the new keyset only. The administrator replaces the registration again.

Between the deletion of a registration and the new registration, the hub rejects requests with unknown_sender. Do each replacement at a quiet time.

Configure the client

In your application code

Parse the hub metadata document as the administrator sent it, and pass it in unchanged. Exactly one hub is the default.

Acme MDM reads the hub metadata of the Acme organization from its configuration, and tags that hub acme:

TypeScript
const hubMetadata: HubMetadata = JSON.parse(hubMetadataJSON);

const cfg: Config = {
	sp: {
		entity: 'acme.example.com',
		callbackUrl: 'https://acme.example.com/stepauth/callback'
	},
	signingKeyset: keyset,
	hubs: [{ tag: 'acme', metadata: hubMetadata, default: true }]
};

const client = createClient(cfg);

The client rejects a hub document without a host, and a host that is not HTTPS.

Send a request

In your application code

The send call of the SDK builds the request, signs it once, and posts it to the hub.

  • It always sets senderId to your entity and recipientId to the hub entity.
  • If you leave them unset, it sets timestamp to now, expiresAt to 30 minutes later, and callbackUrl to the callback URL of your configuration.
  • requestId is yours to set. It is the idempotency key, the id that makes a repeat safe. A retry above the SDK must reuse it. The SDK has a helper that mints a random one.

The result holds the hub response and the pending state. Store that state, keyed by request id, before you tell the caller anything.

Dana, an IT administrator at Acme, clicks Wipe on the laptop MBP-4417, which left the company with a former employee. Acme MDM asks for approval under the laptop-wipe policy:

TypeScript
try {
	const result = await client.send({
		requestId: newRequestId(),
		principal: { subject: email('[email protected]') },
		action: {
			name: 'com.acme.mdm.device.wipe',
			category: 'infra.destroy',
			title: 'Wipe laptop MBP-4417',
			summary: 'Erases every disk on MBP-4417 and removes it from the fleet.',
			details: [entry('device', 'Device', 'MBP-4417'), entry('owner', 'Last user', '[email protected]')],
			approver: policyId('laptop-wipe')
		}
	});
	await pending.put(result.pending.requestId, result.pending);
} catch (err) {
	if (err instanceof SendFailedError) {
		await pending.put(err.state.requestId, err.state);
	}
	throw err;
}

What an approver reads

An approver decides from the text of your request only. The approver has no access to your application. The following parts explain each field of the request.

Write each request for a person who did not start the action. That person must be able to tell from the text that the action is correct.

Field Purpose
action.name Your own identifier for the operation. Required. 120 characters or fewer.
action.title A short title in the imperative. Optional. 120 characters or fewer.
action.summary One or two sentences that state the effect. Required.
action.details The specifics: which resource, which values, which scope.

If you send no title, the hub shows action.name as the title.

Write the summary as the consequence of an approval, not as the name of the operation. The approver decides from it.

Summary Why
Wipe device. Weak. It names the operation, not the device or the effect.
Erases every disk on MBP-4417 and removes it from the fleet. The data cannot be recovered. Strong. It names the target, the effect and the risk.

Email and Slack messages show 12 detail rows or fewer. They cut each value at 200 characters, and they flatten nesting deeper than four levels. Put the rows that matter most first.

Details

A detail is an entry with a key, a label and a value. The approver sees the label and the value, so the label carries the meaning. A value is a string, or a list of child entries. The SDK rejects two entries with the same key in one list. The hub does not check keys.

Acme MDM shows the laptop, its last user, and what the wipe erases:

TypeScript
const details = [
	entry('device', 'Device', 'MBP-4417'),
	entry('owner', 'Last user', '[email protected]'),
	group('scope', 'What the wipe erases', entry('disks', 'Disks', '2'), entry('backups', 'Backups kept', 'None'))
];

NameIDs

A NameID names a person, a group or a policy. On the wire, it is an object with two fields:

Field Meaning
kind email, persistent, group or policy. The kinds that a request can carry.
id The value for that kind. It cannot be empty.

In a decision, decidedBy lists the parties that decided, with the kinds user and group. A request never carries those two kinds.

The SDK has one helper for each kind, and a parse helper that converts the kind:id text that the Web App shows, such as a policy written as policy:laptop-wipe. These guides write a NameID as kind:id for short.

Acme MDM keeps the approver of each action as text in its configuration. It converts the text to a NameID at send time:

TypeScript
const approver = parseNameID('policy:laptop-wipe');

The principal

principal.subject names the party that started the action. It must name one individual:

Form Use
email:[email protected] A person that the organization knows by address.
persistent:<id> A stable identifier that is not an address.

The hub rejects a group: or policy: NameID as the subject.

An agent that acts for a person

An AI agent or an automation can start a sensitive action on behalf of a person. The request then names the agent as the subject and the person as the operator, so the approver sees both. The agent is not blocked for lacking a role. It waits for a person to say yes to this particular action.

When an agent starts the action, set subject to the agent. Set operator to the person that the agent acts for. The chain can have up to five levels.

The Acme cleanup bot wipes MBP-4417 for Dana, who asked it to clean up the laptops of former employees:

TypeScript
const principal = {
	subject: persistent('urn:acme:agent:cleanup-bot'),
	attributes: [entry('agent', 'Agent', 'Cleanup bot')],
	operator: { subject: email('[email protected]') }
};

Action name and category

action.name is your own identifier, in reverse-DNS form, for example com.acme.mdm.device.wipe. The hub does not interpret it.

action.category comes from a closed registry. The hub rejects any other value with unknown_category. The SDK has a constant for each category.

The approver

action.approver is one NameID: the email of a member, the id of a group from the hub directory, or the name of a policy. A request without an approver notifies nobody. Route requests to approvers explains the three forms and their limits.

Expiry

expiresAt is 30 minutes after timestamp by default. If a person waits for the action, set it shorter. If the action can wait for an approver on another shift, set it longer. The hub sets no maximum.

timestamp must be within five minutes of the clock of the hub, in RFC 3339 format. The SDK also compares it with the local clock, before it sends the request.

policyVersion

policyVersion is a free-text tag for your own permission rules, for example acme-mdm-rules@2026-04-15. It records which of your rules decided that the action needs approval. It does not name a hub policy.

What the pending state holds

Field Why it exists
requestId The key that the decision callback uses to find the state.
payload The exact signed bytes, in base64. The decision digest is calculated over them.
hub The local tag of the hub, so the SDK finds the keysets that verify the decision.
hubEntity The hub entity at send time. A later change to the tags cannot change what a decision must match.
result The execution result, after you run the action.

The signature covers the payload bytes exactly as sent. The SDK signs them once, every retry sends the same bytes, and the decision carries a digest of them.

Keep the state, with its recorded result, for 24 hours after a decision arrives. The hub can deliver the same decision again in that time, and the handler answers it with the recorded result. A decision can also arrive after expiresAt, so keep the state of an undecided request past its expiry. The handler needs it to answer 200 with a status of expired. See Handle redelivery .

Retries

The SDK makes up to three attempts. It waits about 1 second, then about 4 seconds. It retries only a transport error or a hub 5xx. Every other error is a verdict on the exact bytes sent, and the same bytes get the same verdict.

Each retry sends the same signed envelope. If a retry gets 409, an earlier attempt reached the hub and its response was lost. The SDK reports an already-submitted result, not an error. The SDK does not follow redirects.

Receive the decision

In your application code

The hub posts the signed decision to your callback URL. The handler takes two functions. The first reads the pending state by request id. The second runs the decision and returns the execution result.

When the IT lead approves the wipe of MBP-4417, Acme MDM wipes the laptop and records the result. When the IT lead denies it, Acme MDM records the denial and does nothing else:

TypeScript
const handle = client.callbackHandler(
	(requestId) => pending.get(requestId),
	async (decision) => {
		let result: ExecutionResult = { requestId: decision.requestId, status: 'success' };
		if (isApproved(decision)) {
			try {
				await runAction(decision.requestId);
			} catch {
				result = { requestId: decision.requestId, status: 'error', error: 'The wipe did not complete.' };
			}
		}
		try {
			await pending.update(decision.requestId, { result });
		} catch (error) {
			console.error(`recording result of ${decision.requestId}`, error);
		}
		return result;
	}
);

export async function POST(request: Request): Promise<Response> {
	const { status, body } = await handle(await request.text());
	return new Response(body ? JSON.stringify(body) : null, {
		status,
		headers: { 'content-type': 'application/json' }
	});
}

Record the result on success and on error. A redelivery of a recorded decision gets the recorded result, and the SDK does not run your function again.

The SDK answers the hub with 200 whether your function returns a result or throws. A recording error therefore never makes the hub resend the decision. A decision can still arrive twice, for example when the hub loses the response. If the first result is not recorded yet, the SDK runs your function again. Make the action safe to repeat for the same request id. When MBP-4417 is already wiped, Acme MDM skips the wipe.

What the handler checks

The handler reads the request id from the envelope, but it does not trust it. It finds the stored state. An unknown id gets 401. Then it checks, in this order:

  1. Both signatures verify against the keysets of the hub.
  2. senderId equals the hub entity recorded at send time.
  3. requestId equals the request id in the stored state.
  4. recipientId equals your own entity.
  5. requestDigest equals the SHA-256 digest of the stored payload bytes.
  6. The decision arrived before expiresAt.

If one of the first five checks does not pass, the handler returns 401 with no body. It never reports the cause, because a difference tells a forger which check to attack next.

A genuine decision that arrives after expiresAt gets 200 with a status of expired. The handler does not run your function.

Handle redelivery

Delivery is at least once. The hub retries for 24 hours until it gets a 2xx response. Any 2xx counts as delivered, with or without a body. At delivery time, the hub applies the callback URL rules again. If a URL no longer meets them, the hub marks the delivery as failed and stops.

  1. Record the execution result in the stored state after you run the action.
  2. Return the stored state for every request id that you issued, also after the decision. The SDK then returns the recorded result.
  3. Make the action idempotent by request id. Two deliveries can arrive at the same time, and both can read the state before either writes it.

If the lookup returns nothing for an id that you issued, the hub reads the 401 as a rejection and retries for a day.

Report the execution result

The body that the handler returns is the execution result: the request id, a status of success or error, and an optional message.

If your function throws, the handler still returns 200 with status: "error" and the message execution failed. The decision was genuine at that point. A 401 would make the hub deliver it again, for an action that is possibly half done.

Test it end to end

In your application code

Ask your administrator to register your application under a test display name, as in Configure the hub , and name yourself as the approver of the test requests. The administrator deletes the registration when you are done, and registers the real one.

A hosted hub cannot reach localhost, and it delivers decisions to an HTTPS URL only. Run a tunnel that forwards to your application, then use the HTTPS URL it prints, plus your callback path, as the callback URL in the service provider metadata:

Shell
cloudflared tunnel --url http://localhost:18090

A quick tunnel gets a new hostname on every start, and the callback URL is part of the registered metadata. Register the service provider again after the hostname changes, or use a named tunnel with a hostname that you own.

Serve only the callback path through the tunnel. Every other page of your application answers to the tunnel’s hostname too, unless your application checks the Host header.

  1. The hub accepts the request and the approver gets an email.
  2. After the approval, the callback receives the decision and your handler returns a result.
  3. After the first result is recorded, a duplicate delivery gets the recorded result and does not run the action again. Two deliveries before the record both run it, so claim the request before you run the action.
  4. A request without an approver, or with an approver outside the directory, is rejected or expires without a decision.

The category registry

Category For
data.read Reading records that are normally not visible.
data.export Moving data out of the system.
data.modify Changing stored records.
data.delete Erasing stored records.
identity.create Creating an account.
identity.modify Changing an account.
identity.escalate Raising the permissions of an account.
identity.disable Disabling an account.
infra.modify Changing infrastructure.
infra.destroy Destroying infrastructure.
financial.transfer Moving money.
financial.approve Approving a financial commitment.
code.deploy Deploying code.
code.release Releasing a version.
communication.send Sending a message for the organization.
access.grant Granting access to a resource.
access.revoke Removing access from a resource.

Errors

Code HTTP Meaning and what to do
unknown_sender 401 No registration at the hub has your entity. Ask the administrator to register your service provider metadata.
invalid_signature 401 The registration does not hold your keyset, or a signature is not valid. Register your current keyset.
wrong_recipient 403 The request is for a different hub. Use the hub metadata of the right organization.
org_locked 403 SplitSecure locked the organization. Requests work again after SplitSecure unlocks it.
org_deactivated 403 The organization is deactivated or scheduled for deletion. Ask its administrator.
request_not_found 404 The hub has no metadata for the organization, or the host is wrong. Copy the hub metadata again.
duplicate_request 409 The SDK never returns this code. It reports an already-submitted result. See the warning below.
malformed_request 400 A required field is missing or has an incorrect value.
unknown_category 400 Use a category from the registry.
unknown_approver 400 The approver is not an active member, or the group or policy does not exist. Use the hub directory.
approver_set_too_large 400 The approver expands to more than 99 groups or 5000 members. Name a smaller group or policy.
operator_depth_exceeded 400 Shorten the operator chain to five levels or fewer.
invalid_callback_url 400 The callback URL is missing, is not HTTPS, or does not match the pattern on the registration.
timestamp_out_of_range 400 The clock of your host is more than five minutes from the clock of the hub. Run NTP.
internal_error 500 The hub had an error. The SDK retries.
internal_error 503 The hub cannot evaluate requests at the moment. The SDK retries.

An answer without an error code, such as a proxy error page, gets the code http_<status> from the SDK, for example http_502. In every SDK, a request for a hub tag that is not in the client configuration gets a local error. The SDK sends nothing.

Only a transport error and a hub 5xx are retryable, and the SDK retries those. Most 4xx codes in the table are a verdict on the exact bytes you sent, and the same bytes get the same answer. org_locked and org_deactivated are different. They depend on the state of the organization, and the directory calls return them too.

Limits

Limit Value
action.name and action.title 120 characters or fewer
Detail rows shown in email and Slack 12, each value cut at 200 characters
Detail nesting shown 4 levels
Operator chain 5 levels
Approver expansion 99 groups and 5000 members
Default expiry 30 minutes after timestamp
Clock skew on timestamp 5 minutes
Request and callback body 1 MiB
Decision redelivery 24 hours, until a 2xx answer
Policy name 120 characters or fewer
Policies per organization 200 or fewer

Troubleshooting

Issue Solution
Every request gets unknown_sender The hub has no registration for your entityExport the service provider metadata and ask the administrator to register it
Every request gets invalid_signature You sign with a keyset that the registration does not hold, for example after a new keysetExport the service provider metadata again. Ask the administrator to replace the registration. See Rotate the keyset
Every request gets wrong_recipient The hub metadata in your configuration belongs to a different organizationGet the hub metadata again from the administrator of the right organization
The client rejects the configuration The hub document has no host or an http host, or more or fewer than one hub is the defaultUse the hub document as copied, with its HTTPS host. Mark exactly one hub as the default
The callback answers 401 to every decision The stored payload is not the signed bytes, or the lookup returns nothingStore the payload of the pending state as is. Do not rebuild it from parsed fields
The hub keeps delivering a decision The callback returns a status that is not 2xxReturn the stored state for every id that you issued, also after the decision
Requests get timestamp_out_of_range The clock of your host is more than five minutes from the clock of the hubRun NTP on the host
The action runs twice Two deliveries of one decision ran your function at the same timeClaim the request row with a conditional write before you run the action. Run it after a successful claim only
No decision arrives, and the send call succeeded The request names no approver, or its requestId was already in useName an approver on every request. Use the request id helper