1. Why StepAuth?
The authorization gap
Your service already has authorization. RBAC or ABAC tells you whether a principal can perform an operation — whether they have the right role, the right attributes, the right scope. This is necessary, but it answers the wrong question for sensitive actions.
An MDM admin can wipe any device. That's their role. But whether wiping this specific device, right now, for this stated reason is appropriate — that's a different question. RBAC grants capability. It doesn't verify intent.
This gap has always existed, but it was manageable when humans were the only actors. Humans self-regulate — they double-check targets, hesitate before irreversible actions, ask a colleague. The authorization system didn't need to encode that judgment because the human provided it implicitly.
Agents change the equation
Your customers are increasingly using AI agents to interact with your service. Agents are capable enough to handle real work — processing refunds, managing infrastructure, modifying accounts — but they don't exercise the same judgment humans do. They don't hesitate. They don't double-check. They execute.
Without a way to verify intent, you're forced into a bad choice:
- Restrict agents to safe operations. The agent can list devices but not wipe them. Can view orders but not issue refunds. This throws away most of the agent's value and frustrates your customers who are trying to automate real workflows.
- Trust agents with broad access. The agent can do everything a human admin can. You hope the prompt engineering is good enough. This is unacceptable risk.
This isn't a hypothetical. It's the blocker your customers hit today when they try to connect agents to your service. The industry has lacked a standard mechanism for intent verification — a way to let agents attempt any action while ensuring the right oversight happens before execution.
What this means for your service
StepAuth removes the tradeoff. When your service integrates StepAuth, your customers can point agents at your full API surface. Sensitive actions get routed for review — the agent isn't blocked by lacking a role, it's blocked until the right person (or policy) confirms this specific action makes sense.
Your customers can use agents without fear. They automate more, rely on your service more, and unlock workflows that weren't previously safe. Services that integrate StepAuth become agent-ready. Services that don't will either block agents from their most valuable operations or accept the liability — neither is competitive as agentic usage grows.
How StepAuth fits with existing authorization
StepAuth doesn't replace RBAC or ABAC. It adds a layer:
- RBAC/ABAC runs first — does this principal have the capability?
- StepAuth runs second — should this principal exercise that capability right now, on this specific target, in this context?
- Execute only after both pass.
Your existing authorization stays in place. StepAuth adds intent verification for the actions where capability alone isn't enough. The SP describes what will happen. The authorizer — configured by your customer, not by you — decides whether it should.
2. Integration models
Option 1: Authorizer-owns-policy (recommended)
If a tenant has StepAuth enabled, all sensitive actions go to the authorizer. The authorizer decides what to do with each one — auto-approve, auto-deny, or route for review. The SP doesn't encode any per-action policy.
This is the recommended model because it preserves a clean separation of concerns:
- No split policy. All authorization policy lives in one place — the authorizer. The SP doesn't need to know which actions require review, how many approvers are needed, or who the reviewers are.
- Tenant autonomy. Tenants control their own policies through their authorizer configuration. They don't need to ask you to change anything when their requirements evolve.
- Simpler SP code. The SP's logic is: "is this tenant StepAuth-enabled? If yes, submit. If no, proceed as normal." No routing tables, no per-action configuration.
The SP's only design decision is which of its actions are "sensitive" — and this is a product-level decision, not a per-tenant policy decision. You define your sensitive action types once in your codebase. If a tenant's authorizer wants to auto-approve some of them, it can. That's the authorizer's job.
Option 2: SP-side routing (not recommended)
The SP's own authorization layer decides per-action whether to call StepAuth, and potentially which authorizer to contact. For example: the RBAC check returns "allowed, but requires StepAuth check with authorizer X."
This works but has drawbacks. Policy is split between the SP and the authorizer — the SP is deciding which actions are "sensitive enough" for StepAuth, which is exactly the kind of decision StepAuth is designed to externalize. If the organization wants to start requiring approval for an action the SP considered low-risk, they have to ask you to change your configuration.
Use this model only if your product has specific requirements that make per-action routing unavoidable.
3. Multi-tenancy
Per-tenant keys
Your service is multi-tenant. Each tenant that enables StepAuth gets its own:
- Ed25519 keypair — generated by the SP, per tenant
- spId — a unique identity registered with the tenant's authorizer
- Authorizer configuration — endpoint URL, authorizer public key(s)
Per-tenant keys isolate blast radius. A compromised key affects only one tenant. Keys can be rotated independently. Different tenants can use different authorizer providers.
Multiple authorizers
A tenant must be able to configure at least one authorizer. Supporting multiple active authorizers is recommended — for migration between authorizer providers, for routing different actions to different authorizers, or for redundancy.
Each authorizer entry has a name (unique within the
tenant's configuration) and an isDefault flag. Exactly one
authorizer must be marked as the default — it serves as the
fallback when routing fails.
When multiple authorizers are active:
- No routing script: The SP sends requests to all of them and accepts the first valid signed decision. This is a topology concern, not a policy concern.
- With a routing script: A CEL expression determines which authorizer(s) to solicit for each request. The SP sends to the selected authorizers and accepts the first valid signed decision.
Data model
Your tenant configuration should look something like:
{
"tenantId": "acme-corp",
"stepauth": {
"enabled": true,
"routingScript": "request.action.category.startsWith(\"infra.\") ? [\"security-team\"] : [\"default\"]",
"authorizers": [
{
"name": "default",
"isDefault": true,
"endpoint": "https://authorizer.example.com",
"spId": "sp_acme_mdm_tenant_acme",
"signingKeyId": "sp-key-2024-acme",
"authorizerPublicKeys": [
{
"keyId": "auth-key-2024",
"algorithm": "ed25519",
"publicKey": "MCowBQYDK2VwAyEA..."
}
]
},
{
"name": "security-team",
"isDefault": false,
"endpoint": "https://security-authorizer.example.com",
"spId": "sp_acme_mdm_tenant_acme_sec",
"signingKeyId": "sp-key-2024-acme-sec",
"authorizerPublicKeys": [
{
"keyId": "sec-auth-key-2024",
"algorithm": "ed25519",
"publicKey": "MCowBQYDK2VwAyEA..."
}
]
}
]
}
}5. Libraries
Official client libraries handle envelope signing, signature verification, and request construction so you don't have to implement the cryptography yourself.
| Language | Package | Repository |
|---|---|---|
| TypeScript / JavaScript | stepauth | stepauth/stepauth-ts |
| Go | stepauth | stepauth/stepauth-go |
The code examples in this guide are shown in both TypeScript and Go. Use the tabs on any code block to switch — all snippets will switch together.
6. Setup
Key generation
For each tenant that enables StepAuth, generate an Ed25519 keypair. Store the private key securely — never in logs, never in API responses.
import { generateKeyPair } from "stepauth";
const [privateKey, publicKey] = generateKeyPair();
// Store privateKey (base64) securely
// Send publicKey (base64) to the authorizer during registrationAuthorizer registration
Registration is out-of-band — the protocol doesn't specify how it happens. Typically, the tenant configures their authorizer with:
- Your
spIdfor this tenant - Your public key (with its
keyIdandalgorithm) - A callback URL pattern (e.g.,
https://*.yourservice.com/stepauth/callback)
In return, you receive and store:
- The authorizer's endpoint URL
- The authorizer's public key(s) (with
keyIdandalgorithm)
Callback URL pattern
Register a callback URL pattern with the authorizer. This prevents a
scenario where a compromised signing key is used to redirect decisions
to an attacker-controlled endpoint. The authorizer will reject any
request whose callbackUrl doesn't match the registered
pattern.
7. Constructing requests
Client setup
Create a client for each tenant using their stored configuration. The client handles signing, envelope construction, and HTTP communication.
import { Client, parsePublicKey } from "stepauth";
const client = new Client({
authorizerUrl: tenantConfig.authorizer.endpoint,
spId: tenantConfig.authorizer.spId,
privateKey: tenantConfig.privateKey, // base64
keyId: tenantConfig.authorizer.signingKeyId,
authorizerPublicKey: parsePublicKey(
tenantConfig.authorizer.authorizerPublicKeys[0].publicKey
),
});Request structure
An authorization request has fixed protocol fields and two descriptive
sections: the principal (who) and the action (what). Use the entry() and group() helpers to build structured attributes and details.
import { entry, group, entries } from "stepauth";
const request = {
callbackUrl: "https://mdm.acme.com/stepauth/callback",
expiresIn: 1800, // 30 minutes
principal: {
type: "human",
attributes: entries(
entry("name", "Name", "Marc Tremblay"),
entry("email", "Email", "it-admin@acme.com"),
),
},
action: {
type: "device.wipe",
category: "infra.destroy",
summary: "If approved, the MacBook Pro assigned to Jane Doe " +
"(serial C02ZX1ABCDEF) will be remotely wiped. All local data " +
"will be permanently erased. This is not reversible.",
details: entries(
group("device", "Device",
entry("name", "Name", "Jane's MacBook Pro 16\""),
entry("serial", "Serial", "C02ZX1ABCDEF"),
),
entry("target", "Target", "jane.doe@acme.com"),
),
},
};Principals
Every principal has a type — human, service, or agent — and an attributes array describing it. Include whatever context
you have: name, email, role, IP address, session metadata.
For service and agent types, the operator field traces who is behind the action. An AI agent
has an operator (often a human). A service calling your API on behalf of
an agent has an operator chain: service → agent → human.
const request = {
// ...
principal: {
type: "agent",
attributes: entries(
entry("name", "Name", "Support Agent (v3)"),
entry("model", "Model", "claude-opus-4-6"),
),
operator: {
type: "human",
attributes: entries(
entry("email", "Email", "customer@example.com"),
entry("ticket", "Ticket", "TK-90421"),
),
},
},
// ...
};Include as many operator levels as you know, up to 5 total principals.
The human type cannot have an operator — humans are always terminal in the chain.
Actions and summaries
The action.type is a machine-readable identifier you
define — device.wipe, order.refund, access.escalate. It's your namespace; use whatever
naming convention fits your product.
The action.summary is the most important field in the
entire request. It must describe the consequence of
approval — not the operation name, not the API endpoint,
but what will happen in the real world if this is approved.
Good: "If approved, the MacBook Pro assigned to Jane Doe (serial C02ZX1ABCDEF) will be remotely wiped. All local data will be permanently erased and the device will be returned to factory settings. This action is not reversible."
A reviewer reading only the summary should have enough context to make a decision. The summary is the contract — if it says "wipe this device," that's what must happen on approval.
Categories
The optional action.category field classifies the action
using a standard vocabulary so the authorizer can write cross-SP
policies. For example, an authorizer can require VP approval for all infra.destroy actions regardless of which SP they come from.
Categories are free-form strings. The spec suggests a starting set
— data.read, data.export, data.delete, identity.escalate, infra.destroy, financial.transfer, etc.
— but you can use any value that makes sense for your domain.
Details
The action.details array provides structured context. Each
entry has a machine-readable key, a human-readable label, and a value (string or nested array).
The key fields enable policy evaluation — the
authorizer can rewrite the array into a flat JSON object and run it
through a policy engine like Rego. The label fields are
for human display. Include both.
8. Signing and submitting
Submitting a request
The client handles signing, envelope construction, and HTTP
communication. Call submit() with your request object.
const result = await client.submit(request);
// result.status is "pending" — the request is awaiting review
// result.requestId is the unique identifier for this request
console.log(`Request ${result.requestId} submitted, status: ${result.status}`);Handling the response
The submit() method returns a SubmitResponse with the request ID and status. If the authorizer returns an error, the
client throws (TypeScript) or returns an error (Go):
APIError— the authorizer returned a structured error (4xx/5xx). Contains the HTTP status code and error body. See Error Handling.- Network errors — the authorizer was unreachable. Retry with backoff, then fall back to the tenant's unavailability policy.
import { APIError } from "stepauth";
try {
const result = await client.submit(request);
await blockAction(result.requestId);
} catch (err) {
if (err instanceof APIError) {
console.error(`Authorizer error ${err.statusCode}: ${err.body}`);
}
// Block the action on any error
await blockAction(request.requestId);
throw err;
}9. Receiving decisions
When a request is pending, you need to receive the decision asynchronously. There are three mechanisms, from most to least preferred.
Webhook callbacks (primary)
If you provided a callbackUrl in the request, the
authorizer will POST the signed decision envelope to that URL when
a decision is made. The library provides a handler that verifies the
signature and calls your function with the parsed decision.
import { callbackHandler } from "stepauth";
app.post("/stepauth/callback", callbackHandler(
authorizerPublicKey,
async (decision) => {
const request = await getRequest(decision.requestId);
if (!request || request.decidedAt) {
return { requestId: decision.requestId, status: "success" };
}
await markDecided(decision.requestId, decision);
if (decision.decision === "approved") {
const result = await executeAction(request);
return {
requestId: decision.requestId,
status: result.success ? "success" : "error",
...(result.error && { error: result.error }),
};
}
await notifyPrincipal(request, decision);
return { requestId: decision.requestId, status: "success" };
}
));The handler verifies the authorizer's signature, parses the decision, calls your function, and returns the execution result as the HTTP response. Your callback URL must use HTTPS and must match the pattern you registered with the authorizer.
Polling (fallback)
If callbacks aren't practical (e.g., firewalled environments), use
polling. The client provides two methods: poll() for a single check, and waitForDecision() which polls until a decision arrives.
// Single poll — throws PendingError if not yet decided
import { PendingError } from "stepauth";
try {
const decision = await client.poll(requestId);
await handleDecision(decision);
} catch (err) {
if (err instanceof PendingError) {
// Still waiting — try again later
}
}
// Or: poll until decided (with 5-second interval)
const decision = await client.waitForDecision(requestId, 5000);
await handleDecision(decision);Which method to use
| Method | Best for | Tradeoffs |
|---|---|---|
| Webhook | Most integrations | Requires a publicly reachable HTTPS endpoint |
| Polling | Simple setups, firewalled environments | Higher latency, more load on authorizer |
You can combine methods. For example, use webhooks as the primary delivery mechanism and polling as a fallback for retries or missed callbacks.
10. Verifying and executing
Signature verification
Every decision arrives as a signed envelope. The library's callback handler and poll methods verify the authorizer's signature automatically — you only receive parsed, verified decisions.
If you need to verify envelopes manually (e.g., processing from a
queue), use verifyEnvelope():
import { verifyEnvelope, parsePublicKey } from "stepauth";
const publicKey = parsePublicKey(authorizerPublicKeyBase64);
const [payload, keyId] = verifyEnvelope(envelopeJson, publicKey);
// payload is the verified raw bytes, keyId is the signing key IDChecking requestId and spId
After receiving a verified decision, confirm that:
requestIdmatches a request you actually generated. Never accept a decision for an unknown request.spIdmatches your own identity for this tenant. This prevents cross-SP replay — a decision intended for a different service being delivered to you.- The request has not already been decided. Use
requestIdas an idempotency key to prevent double execution. - The request has not expired (
expiresAthas not passed).
Acting on the decision
async function handleDecision(decision: Decision) {
const request = await getRequest(decision.requestId);
if (!request) throw new Error("Unknown requestId");
if (request.decidedAt) return; // Already handled (idempotency)
await markDecided(decision.requestId, decision);
switch (decision.decision) {
case "approved":
return await executeAction(request);
case "denied":
case "expired":
await notifyPrincipal(request, decision);
return { success: true };
}
}Reporting the execution result
If the decision was delivered via webhook, your callback response includes the execution result. This closes the feedback loop — the authorizer knows whether the approved action actually succeeded, and can notify the approvers if it failed.
// Success
{ "requestId": "req_a1b2c3d4e5f6", "status": "success" }
// Failure
{
"requestId": "req_a1b2c3d4e5f6",
"status": "error",
"error": "Device is no longer enrolled in MDM."
}11. Cancellation
If the principal revokes their request or the action is no longer
needed, cancel the pending authorization request. Cancellation is
authenticated — you submit a signed envelope containing the requestId, spId, and current timestamp. The libraries don't wrap this call yet, so
you use the Signer directly.
import { Signer } from "stepauth";
const signer = Signer.create(tenantConfig.privateKey, tenantConfig.keyId);
const cancelPayload = JSON.stringify({
requestId: "req_a1b2c3d4e5f6",
spId: tenantConfig.spId,
timestamp: new Date().toISOString()
});
const envelope = signer.signEnvelope(cancelPayload);
const response = await fetch(
`${tenantConfig.authorizerUrl}/v1/authorization-requests/cancel`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: envelope
}
);
if (response.status === 200) {
// Successfully cancelled — do not execute the action
}
if (response.status === 409) {
// Already decided — retrieve and handle the decision
}After cancellation, you must not execute the action — even if a decision arrives afterward. If the authorizer returns 409, the request was already decided; retrieve the decision and handle it normally.
12. Error handling
Standard error codes
All authorizer error responses use a standard format with a
machine-readable error code and a human-readable message.
| Error code | HTTP | Meaning | SP action |
|---|---|---|---|
invalid_signature | 401 | Your signature didn't verify | Check key configuration, alert your team |
unknown_sp | 401 | spId not registered | Check registration, alert your team |
malformed_request | 400 | Payload failed schema validation | Log and fix the request construction |
invalid_callback_url | 400 | callbackUrl not HTTPS or doesn't match pattern | Fix the callback URL |
operator_depth_exceeded | 400 | Principal chain exceeds 5 levels | Trim the operator chain |
duplicate_request | 409 | requestId already exists | Generate a new requestId |
rate_limited | 429 | Too many requests | Wait for Retry-After duration |
Retry strategy
- 401 errors — do not retry. These indicate a configuration problem (wrong key, unregistered spId). Alert your operations team.
- 400 errors — do not retry. The request is malformed. Log the error and fix the request construction.
- 429 errors — retry after the
Retry-Afterduration. - 5xx errors — retry with exponential backoff.
- Network errors — retry with exponential backoff. Same as 5xx.
Unavailability policy
If all configured authorizers remain unreachable after exhausting the retry policy, the SP's behavior is determined by the tenant's unavailability policy — a per-tenant configuration option.
- Fail-closed (default): The action is treated as denied. The principal is notified that the authorizer is unreachable and the action was blocked.
- Fail-open (opt-in): The action proceeds without authorization. The SP must log that the action was executed without authorizer confirmation, including the retry attempts made and the full request that would have been submitted.
Fail-open exists for availability-critical operations — for example, emergency production access when the authorizer itself may be affected by the incident. It must be an explicit opt-in by the tenant administrator, never a default.
13. Best practices
Writing effective summaries
The summary is the most important field in the protocol. It's what reviewers read to make their decision. Guidelines:
- Describe the consequence, not the operation. "If approved, all 15,832 customer records will be exported as CSV" is better than "Export customer data."
- Include specifics. Names, quantities, targets, durations. A reviewer shouldn't need to look up context elsewhere.
- State irreversibility. If the action can't be undone, say so explicitly.
- Write for someone without domain expertise. The reviewer may not know your product's internals. Plain language wins.
Choosing action types and categories
- Action types are yours. Use a consistent naming
convention —
device.wipe,order.refund,access.escalate— and document them for your customers. - Categories are shared. Use the suggested categories from the spec when they fit. This lets authorizers write cross-SP policies without knowing your specific action types.
Idempotency
Decisions may arrive more than once — via webhook retry, polling,
and streaming simultaneously. Use requestId as an
idempotency key. The action must execute at most once per requestId, regardless of how many times the decision is
delivered.
Defining your sensitive actions
Not every action in your service needs StepAuth. The set of actions you
gate is a product decision. A useful heuristic: if the action is
irreversible, affects other users, moves money, or touches
infrastructure, it's a candidate. list.devices is not. device.wipe is.
Define this set once in your codebase. When a tenant enables StepAuth, all of these actions go to their authorizer. The authorizer decides which ones actually need review — your job is just to surface them.
Security hygiene
- Store private keys securely. Never log them or return them in API responses.
- Rotate keys periodically. The
keyIdfield supports seamless rotation — register a new key, start signing with it, then deregister the old one. - Never log signed envelopes containing private data. Log
requestIdand decision outcomes, not payloads. - Validate callback URLs match your registered pattern. Never accept a decision delivered to an unexpected endpoint.