Sovereign Signing Guide
Step-by-step setup for out-of-band JWT signing with external agents
What is Sovereign Signing?
Sovereign Signing moves JWT signing authority to external agents running on separate servers. The Orion server
never possesses the private key. Even with full database and server compromise, an attacker cannot forge tokens.
Use sovereign signing when:
- You need token signing authority physically separated from the identity server
- You want agents to enforce custom policies (approval prompts, geo-restrictions) before signing
- Compliance requires hardware-isolated signing keys (HSM, secure enclave)
How it works
Browser login: User logs in normally → Orion shows a brief "Authorizing..." spinner →
an external agent receives the unsigned token, signs it with its private key, and submits it back →
user is redirected with a valid auth code. Token exchange is instant (the token is pre-signed).
Machine-to-machine: Client calls the token endpoint → Orion signals the agent →
agent signs within seconds → token returned. If the agent doesn't respond within the timeout, the client
gets authorization_pending and can retry.
Two ways to set this up.
The fastest path is the admin UI at
/sovereign/manage/{your-org}/pools
— create pools, register agents (with optional server-side key generation), assign a pool to an app from
the application settings page, and view signing requests, all point-and-click. The steps below show the
equivalent API/CLI path for automation. The agent itself can be built with the
SovereignAgentClient SDK class
(see Step 5) instead of raw HTTP.
1 Create a Signing Pool
UI: go to /sovereign/manage/your-org/pools and use the "Create Pool" form. Or via API:
A pool is a named group of agents. Multiple agents can belong to the same pool for redundancy.
# Create a pool for your application
curl -X POST https://login.shanecraven.com/oauth/your-org/sovereign/admin/pools \
-H "Content-Type: application/json" \
-d '{
"poolId": "guardian-pool",
"name": "Guardian Signing Pool"
}'
# Response:
{
"id": 1,
"poolId": "guardian-pool"
}
2 Generate Agent RSA Key Pair
Generate a 2048-bit (minimum) or 4096-bit RSA key pair on the machine where your agent will run.
# Generate private key (keep this secret — it stays on the agent server)
openssl genrsa -out agent-private.pem 4096
# Export the public key (this gets registered with Orion)
openssl rsa -in agent-private.pem -pubout -outform DER | base64 > agent-public-key.b64
# The base64 content of agent-public-key.b64 is what you pass as publicKeyPem
Important: The public key must be base64-encoded DER format (not PEM with headers).
This is what openssl rsa -outform DER | base64 produces.
3 Register the Agent
Register the agent with its public key. You'll receive an API key (shown only once).
Recommended: Use
Device Identity instead of API keys for agent authentication.
Enroll the agent as a device (with optional TPM attestation), then use the
SovereignAgentClient
device-auth constructor — no shared secrets to manage. The API key path below is for back-compatibility.
# Read your base64-encoded public key
PUBLIC_KEY=$(cat agent-public-key.b64)
curl -X POST https://login.shanecraven.com/oauth/your-org/sovereign/admin/agents \
-H "Content-Type: application/json" \
-d "{
\"poolId\": \"guardian-pool\",
\"agentId\": \"agent-01\",
\"name\": \"Primary Signing Agent\",
\"publicKeyPem\": \"$PUBLIC_KEY\",
\"webhookUrl\": \"https://agent.internal:8443/signing-webhook\"
}"
# Response (save the apiKey — shown only once!):
{
"id": 1,
"agentId": "agent-01",
"keyId": "guardian-pool-v1",
"keyVersion": 1,
"apiKey": "Rk9PQkFSLi4u..." // ← Save this! Agent uses it to authenticate
}
Optional fields:
webhookUrl — if set, Orion will POST to this URL when a signing request is created (lower latency than polling)
keyId — custom key ID for the JWT header. Defaults to {poolId}-v{version}
certificateChainPem — X.509 cert chain for cert-chain validation mode (see Step 7)
algorithm — defaults to RS256
4 Assign Pool to Your Application
There are two ways to enable sovereign signing for an OAuth application:
Option A: Environment variable (takes precedence)
# Format: ORION_SOVEREIGN_SIGNING_POOL__<CLIENT_ID_UPPERCASE>=pool-id
# Example for app with PublicId "guardian-app":
export ORION_SOVEREIGN_SIGNING_POOL__GUARDIAN_APP=guardian-pool
Option B: Database (via the Application Manager or API)
Set the SovereignSigningPoolId field on the Application entity. This can be done through
the admin UI or by updating the database directly. The pool's database ID (not the poolId string) is stored
in this FK column.
Once assigned, ALL token issuance for that application goes through sovereign signing.
Test with a non-critical app first.
5 Build the Signing Agent
The agent runs on your secure server. It polls for signing requests, signs the JWT, and submits it back.
Recommended: SovereignAgentClient (SDK)
The SDK ships a turnkey client that handles the claim → decide → sign → submit loop. You just supply the private key and an approve/reject callback.
using OrionDotNetCore.Sovereign;
var agent = new SovereignAgentClient(
serverBaseUrl: "https://login.shanecraven.com",
orgPublicId: "your-org",
apiKey: "Rk9PQkFSLi4u...", // from registration
privateKeyPem: File.ReadAllText("agent-private.pem"),
keyId: "guardian-pool-v1");
// Approve everything (add your own policy checks here):
await agent.RunAsync(req =>
{
// Inspect req.ClientId / req.UserId / req.TokenType and decide.
return SovereignDecision.Approve();
// or: return SovereignDecision.Reject("not allowed");
});
That's the entire agent. RunAsync polls until cancelled; pass a CancellationToken to stop it. The manual HTTP version below is for non-.NET agents.
Manual flow (any language) — C# example
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using Microsoft.IdentityModel.Tokens;
var agentApiKey = "Rk9PQkFSLi4u..."; // From step 3
var baseUrl = "https://login.shanecraven.com/oauth/your-org";
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", agentApiKey);
// Load private key
var rsa = RSA.Create();
rsa.ImportFromPem(File.ReadAllText("agent-private.pem"));
var signingKey = new RsaSecurityKey(rsa) { KeyId = "guardian-pool-v1" };
while (true)
{
// 1. Claim the next pending request
var claimResp = await client.PostAsJsonAsync($"{baseUrl}/sovereign/agent/claim", new {});
var claim = await claimResp.Content.ReadFromJsonAsync<ClaimResponse>();
if (!claim.Claimed)
{
await Task.Delay(2000); // No pending requests, wait and retry
continue;
}
// 2. (Optional) Apply your own policy checks here
// e.g. check client_id, user, time of day, geo, etc.
// To reject: POST /sovereign/agent/reject
// To challenge user: POST /sovereign/agent/challenge
// 3. Sign the unsigned payload
var unsignedPayload = claim.UnsignedPayload; // "base64url-header.base64url-payload"
var dataToSign = Encoding.UTF8.GetBytes(unsignedPayload);
var signature = rsa.SignData(dataToSign, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var signedJwt = unsignedPayload + "." + Base64UrlEncoder.Encode(signature);
// 4. Submit the signed JWT
await client.PostAsJsonAsync($"{baseUrl}/sovereign/agent/sign", new
{
requestId = claim.RequestId,
signedJwt = signedJwt
});
}
Agent flow (curl / bash for testing)
# Claim a request
CLAIM=$(curl -s -X POST https://login.shanecraven.com/oauth/your-org/sovereign/agent/claim \
-H "Authorization: Bearer $AGENT_API_KEY" \
-H "Content-Type: application/json" -d '{}')
REQUEST_ID=$(echo $CLAIM | jq -r '.requestId')
PAYLOAD=$(echo $CLAIM | jq -r '.unsignedPayload')
# Sign it with the agent's private key
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -sign agent-private.pem | base64 | tr '+/' '-_' | tr -d '=')
SIGNED_JWT="${PAYLOAD}.${SIGNATURE}"
# Submit
curl -X POST https://login.shanecraven.com/oauth/your-org/sovereign/agent/sign \
-H "Authorization: Bearer $AGENT_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"requestId\": \"$REQUEST_ID\", \"signedJwt\": \"$SIGNED_JWT\"}"
6 Configure the SDK (Consuming App)
The consuming application's SDK must be configured to validate tokens against the sovereign pool's keys
— not the main server JWKS. Use the AddSovereignSigning
helper and choose a validation mode based on your security requirements:
Using the AddSovereignSigning helper (recommended)
using OrionDotNetCore.Sovereign;
services.AddSovereignSigning(o =>
{
o.FederationServer = "https://login.shanecraven.com/federation";
o.OAuthOrganisationId = "your-org";
o.OAuthClientId = "guardian-app";
o.PoolId = "guardian-pool";
o.KeyThumbprints = new[] { "sha256:YOUR_KEY_THUMBPRINT" }; // or PublicKeys / TrustedRootCertificates
});
The raw FederationConfig forms below are equivalent if you prefer them.
Thumbprint pinning (simplest production mode)
// Compute the agent's public key thumbprint:
// SHA256(RSA_Modulus_bytes || RSA_Exponent_bytes) → base64
// The SDK computes this for each key in JWKS and compares against your allowlist.
services.AddSingleton<IFederationConfig>(new FederationConfig
{
FederationServer = "https://login.shanecraven.com/federation",
FilterType = FederationFilterType.OAuthUserBearer,
OAuthOrganisationId = "your-org",
OAuthClientId = "guardian-app",
SovereignSigningPoolId = "guardian-pool",
SovereignKeyThumbprints = new[] { "sha256:YOUR_KEY_THUMBPRINT_HERE" }
});
Embedded key (maximum isolation, no JWKS fetch)
services.AddSingleton<IFederationConfig>(new FederationConfig
{
FederationServer = "https://login.shanecraven.com/federation",
FilterType = FederationFilterType.OAuthUserBearer,
OAuthOrganisationId = "your-org",
OAuthClientId = "guardian-app",
SovereignPublicKeys = new[] { "-----BEGIN PUBLIC KEY-----\nMIIBI..." }
});
See the SDK Integration page for cert chain mode and all validation options.
7 Cert Chain Mode (Optional, recommended for production PKI)
For environments with HSM/PKI infrastructure, you can sign agent certificates with a root CA.
The SDK validates the cert chain — no thumbprint updates needed when you rotate keys.
Setup
# 1. Generate a root CA (store private key in HSM)
openssl req -x509 -newkey rsa:4096 -keyout root-ca-key.pem -out root-ca-cert.pem \
-days 3650 -subj "/CN=Guardian Signing Root" -nodes
# 2. Create a CSR for the agent key
openssl req -new -key agent-private.pem -out agent.csr -subj "/CN=guardian-pool-v1"
# 3. Sign the agent cert with the root CA
openssl x509 -req -in agent.csr -CA root-ca-cert.pem -CAkey root-ca-key.pem \
-CAcreateserial -out agent-cert.pem -days 365
# 4. Create the cert chain (leaf first, then root)
cat agent-cert.pem root-ca-cert.pem > agent-chain.pem
# 5. Register the agent with the cert chain
CHAIN=$(cat agent-chain.pem)
curl -X POST https://login.shanecraven.com/oauth/your-org/sovereign/admin/agents \
-H "Content-Type: application/json" \
-d "{
\"poolId\": \"guardian-pool\",
\"agentId\": \"agent-02\",
\"publicKeyPem\": \"$(cat agent-public-key.b64)\",
\"certificateChainPem\": \"$CHAIN\"
}"
SDK configuration (cert chain mode)
services.AddSingleton<IFederationConfig>(new FederationConfig
{
FederationServer = "https://login.shanecraven.com/federation",
FilterType = FederationFilterType.OAuthUserBearer,
OAuthOrganisationId = "your-org",
OAuthClientId = "guardian-app",
SovereignSigningPoolId = "guardian-pool",
SovereignTrustedRootCertificates = new[] { File.ReadAllText("root-ca-cert.pem") }
});
Key rotation with cert chain mode: Generate a new key pair, sign it with the same root CA,
register the new agent — the SDK automatically trusts it (same root). No SDK redeploy needed.
8 Key Rotation
To rotate an agent's signing key without downtime:
- 1. Generate a new key pair on the agent server
- 2. Register a new agent with the new public key (gets a new
keyVersion and keyId like guardian-pool-v2)
- 3. Update the SDK config if using thumbprint mode (add the new thumbprint to the allowlist)
- 4. Switch the agent to sign with the new key
- 5. Both old and new keys are served in the pool JWKS during the transition
- 6. After a grace period, deactivate the old agent:
DELETE /sovereign/admin/agents/agent-01
With cert chain mode, step 3 is not needed — any key signed by your root CA is automatically trusted.
What Users See
During a sovereign-signed login flow:
- User logs in normally (email/password, passkey, etc.)
- A dark-themed "Authorizing your session" page appears with a spinner
- If the agent issues a challenge, a prompt appears inline (e.g., "Approve sign-in for john@example.com?")
- Once signed, the user is redirected to the application (typically <1 second)
- If the agent rejects or times out, the user sees an error message
The wait page uses Server-Sent Events (SSE) for real-time updates — no page refresh needed.
Troubleshooting
| Symptom |
Cause & Fix |
| Wait page spins forever |
Agent not running or not polling. Check agent logs. Verify the agent's API key is correct and the pool assignment matches. |
| Agent sign returns 400 "payload mismatch" |
The header.payload portion of the signed JWT must be byte-identical to the unsignedPayload from the claim response. Don't modify the payload before signing. |
| SDK rejects valid sovereign tokens |
Check validation mode config: if using thumbprint mode, verify the thumbprint matches. If cert chain mode, verify the root cert in SDK config matches the CA that signed the agent cert. |
client_credentials returns authorization_pending |
Agent didn't sign within ORION_SOVEREIGN_M2M_TIMEOUT_SECONDS (default 30s). Increase timeout, add more agents to the pool, or use webhook mode for lower latency. |
| Pool JWKS returns empty keys array |
No active agents in the pool. Register an agent or check that existing agents have IsActive = true. |
| Agent webhook not being called |
Verify the webhookUrl is reachable from the Orion server. Check agent registration includes the URL. Webhook failures don't block the flow — agents can still poll. |
Verify Your Setup
# 1. Check pool JWKS serves the agent's key
curl https://login.shanecraven.com/oauth/your-org/sovereign/pools/guardian-pool/jwks.json
# Should return: {"keys":[{"kty":"RSA","kid":"guardian-pool-v1",...}]}
# 2. Check main JWKS does NOT include the agent's key
curl https://login.shanecraven.com/oauth/your-org/.well-known/jwks.json
# Should NOT contain "guardian-pool-v1"
# 3. Check pool discovery document
curl https://login.shanecraven.com/oauth/your-org/sovereign/pools/guardian-pool/.well-known/openid-configuration
# Should point jwks_uri to the pool JWKS
# 4. Test the agent queue (with agent auth)
curl -H "Authorization: Bearer $AGENT_API_KEY" \
https://login.shanecraven.com/oauth/your-org/sovereign/agent/queue
# Should return: {"requests":[]}