Actions & Sandboxed Execution
The Problem: Extensibility Without Forking
Every identity platform eventually confronts the same tension: customers need custom logic in their authentication flows, but the platform cannot anticipate every requirement. Without an extensibility mechanism, the options are:
| Approach | Problem |
|---|---|
| Feature requests | Slow, blocks customers, every use case is different |
| Webhooks to external services | Adds latency, requires customer to host a webhook server, cannot block the flow synchronously |
| Fork the platform | Maintenance nightmare, blocks upgrades |
| Configuration-only rules | Too limited for real-world logic |
What customers actually need is the ability to run custom code at specific points in the authentication flow — before login (to block suspicious attempts), after login (to enrich tokens with custom claims), after signup (to trigger onboarding workflows), and more.
The challenge is doing this safely. Customer code runs inside the authorization server, which is the most security-critical component of the entire infrastructure. A bug in custom code must not crash the server, leak data from other tenants, or bypass authentication controls.
Industry Approaches
Different identity providers solve this problem in fundamentally different ways:
| Provider | Mechanism | Language | Execution Model | Latency Impact |
|---|---|---|---|---|
| Auth0 | Actions | JavaScript/TypeScript | Isolated VM (Deno-like sandbox) | Medium (VM startup) |
| Okta | Hooks (Inline/Event) | N/A (webhook) | External HTTP call to customer server | High (network round-trip) |
| Firebase | Extensions | TypeScript | Cloud Functions (separate process) | High (cold start + execution) |
| Keycloak | SPIs (Service Provider Interfaces) | Java | Classpath plugin, loaded at boot | Low (same process) |
| WorkOS | Webhooks + Rules | N/A | External HTTP + declarative rules | Varies |
| Auris | Actions | JavaScript | Sandboxed Function constructor | Low (same process, no VM) |
Each approach makes a different trade-off between isolation, performance, and developer experience:
- V8 Isolates (Auth0): Strong isolation, but VM startup adds latency and infrastructure complexity.
- Webhooks (Okta): Complete isolation (runs on customer’s infrastructure), but adds network latency and requires the customer to operate a server.
- Classpath plugins (Keycloak): Zero overhead, but requires Java expertise, complex deployment, and a server restart to update.
- Function constructor (Auris): Very low overhead, runs in the same Node.js process, with isolation enforced at the code analysis level rather than the runtime level.
How the Auris Sandbox Works
Auris Actions execute custom JavaScript within a controlled environment using the JavaScript Function constructor. This mechanism creates a function from a string of code with an explicit scope — the action code only sees variables that Auris explicitly provides.
The Execution Pipeline
Step 1: Static Analysis
Before executing any code, Auris scans the action’s source for blocked patterns:
const BLOCKED_PATTERNS = [
'require', // No module loading
'import', // No ES module imports
'process', // No access to process.env, process.exit, etc.
'global', // No access to the global object
'child_process', // No subprocess spawning
'fs', // No filesystem access
]If any blocked pattern is found in the source code, the action is rejected before execution. This is a defense-in-depth measure — even if the runtime sandbox has a gap, the static analysis catches obvious escape attempts.
Static analysis is not a security boundary by itself. A determined attacker could obfuscate blocked keywords. The static analysis exists to catch accidental misuse and provide clear error messages. The real security comes from the restricted scope object provided to the constructed function.
Step 2: Function Construction
The action code string is used to construct a function with context as its sole parameter. The resulting function behaves as:
// Conceptually equivalent to:
function anonymous(context) {
// actionCode runs here
// It can ONLY access:
// - 'context' (the argument)
// - JavaScript built-ins (Array, Object, String, Math, Date, JSON, etc.)
// - fetch (explicitly provided for outbound HTTP)
// - console (captured, not real stdout)
}The critical property: the function has no closure over the server’s variables. It cannot access require, process, __dirname, database connections, or any other server-side state. It only sees what is passed in the context parameter.
Step 3: Context Object
The context parameter provides the action with information about the current authentication event and limited capabilities:
interface ActionContext {
// Identity information
user: {
id: string
email: string
username: string
roles: string[]
metadata: Record<string, unknown>
}
// Request information
request: {
ip: string
userAgent: string
geoip?: { country: string; city: string }
}
// Tenant information
tenant: {
id: string
name: string
}
// Application information
application: {
id: string
clientId: string
name: string
}
// Trigger-specific data
trigger: string // 'pre_login' | 'post_login' | 'pre_signup' | 'post_signup' | ...
// Allowed utilities
fetch: typeof fetch // Outbound HTTP (for webhook-style integrations)
console: { // Captured logging (goes to ActionLog, not stdout)
log: (...args: unknown[]) => void
warn: (...args: unknown[]) => void
error: (...args: unknown[]) => void
}
}Step 4: Timeout Enforcement
Every action execution is wrapped in a Promise.race() with a configurable timeout (default 5 seconds):
const result = await Promise.race([
fn(context),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Action timed out')), action.timeout)
),
])If the action does not resolve within the timeout, it is treated as a failure. The timeout prevents infinite loops, hanging HTTP calls, and other denial-of-service vectors.
Step 5: Result Processing
The action’s return value determines what happens next:
interface ActionResult {
// Pre-triggers can block the flow
allow?: boolean // false = block the login/signup
// Post-triggers can add custom claims to the JWT
claims?: Record<string, unknown>
// Actions can add metadata to the user record
metadata?: Record<string, unknown>
// Human-readable denial reason (shown to user if allow=false)
reason?: string
}The Trigger Pipeline
Actions are organized by trigger — the point in the authentication flow where they execute. Each trigger has specific semantics:
| Trigger | When It Fires | Can Block? | Can Enrich Token? |
|---|---|---|---|
pre_login | After credentials are validated, before session creation | Yes | No |
post_login | After successful login, before token issuance | No | Yes (add claims) |
pre_signup | After signup form validation, before user creation | Yes | No |
post_signup | After user is created in the database | No | Yes (add metadata) |
post_change_password | After password change completes | No | No |
pre_m2m_token | Before issuing an M2M token via client_credentials | Yes | Yes (add claims) |
Sequential Execution
Multiple actions can be registered for the same trigger. They execute sequentially in the order defined by the order field on the Action model:
pre_login actions (ordered by 'order' field):
Action 1 (order: 1) → "Block disposable emails"
Action 2 (order: 2) → "Check IP reputation"
Action 3 (order: 3) → "Log login attempt to SIEM"If Action 1 returns { allow: false }, Actions 2 and 3 are skipped. The login is blocked immediately.
If Action 2 throws an unhandled exception, the flow is blocked by default (fail-secure). This prevents a broken action from accidentally allowing unauthorized access.
Fail-Secure Default
Action throws Error → Flow is BLOCKED (not silently passed through)This is a deliberate design choice. The alternative — failing open (allowing the flow to continue if an action throws) — would mean that a misconfigured or buggy action could silently disable security checks. Auris treats action failures as blocking by default.
If you want a specific action to be non-blocking (advisory only), wrap your action code in a try-catch and always return { allow: true }. This way, exceptions are caught within the action and the flow continues.
The Security Model in Detail
What Actions CAN Do
| Capability | How |
|---|---|
| Make outbound HTTP calls | await context.fetch('https://api.example.com/check', { ... }) |
| Read the current user’s profile | context.user.email, context.user.roles |
| Read request metadata | context.request.ip, context.request.userAgent |
| Block authentication | return { allow: false, reason: 'Blocked by policy' } |
| Add JWT claims | return { claims: { department: 'engineering' } } |
| Add user metadata | return { metadata: { onboardingStep: 3 } } |
| Log messages | context.console.log('Checked IP:', context.request.ip) |
| Use JavaScript built-ins | JSON.parse(), Date.now(), Math.random(), Array.from(), etc. |
What Actions CANNOT Do
| Restriction | Why |
|---|---|
Load modules (require, import) | Prevents access to Node.js APIs (fs, child_process, net, etc.) |
Access process | Prevents reading env vars (process.env.DATABASE_URL), exiting the server (process.exit()) |
Access global / globalThis | Prevents escaping the sandbox scope |
| Create new execution contexts | Nested dynamic code construction is blocked by static analysis |
| Access the filesystem | No fs module available; fetch is the only I/O mechanism |
| Access the database | No Prisma client or database connection in scope |
| Access other tenants’ data | context only contains data for the current tenant and user |
| Run indefinitely | Promise.race() timeout enforced (default 5 seconds) |
| Write to stdout/stderr | console is captured and redirected to ActionLog |
Tenant Isolation
Each action belongs to a specific tenant (via tenantId on the Action model). Actions from one tenant cannot see or affect another tenant’s authentication flows. The context object only contains data from the executing tenant — there is no mechanism for cross-tenant access.
Performance Characteristics
The Function constructor approach trades strong isolation for low latency. Here are the measured performance characteristics:
| Operation | Typical Latency | Notes |
|---|---|---|
| Static analysis (blocked patterns) | <0.1ms | Simple string search |
| Function construction | ~0.5ms | One-time per execution (not cached, for security) |
| Simple logic (no HTTP) | 1-5ms | Conditionals, string operations, JSON manipulation |
| With one outbound HTTP call | 50-500ms | Dominated by external service latency |
| With multiple HTTP calls | Sum of calls | Sequential by default; can use Promise.all() for parallel |
| Timeout | 5,000ms (default) | Configurable per-action (1s-30s range) |
Impact on Login Latency
The key question: how much does the Actions Engine add to a login request?
- Pre-login actions: Directly add to login latency (the login cannot proceed until all pre-login actions complete or block)
- Post-login actions: Add to token issuance latency (the tokens are not returned until post-login actions complete)
- No actions configured: Zero overhead (the engine checks for active actions via a database query, but returns immediately if none exist)
For a typical setup with one pre-login action (IP check, no HTTP) and one post-login action (add custom claims, no HTTP):
Login without actions: ~150ms
Login with 2 actions: ~155ms (+5ms)
Login with HTTP action: ~350ms (+200ms, dominated by HTTP call)If your action makes outbound HTTP calls, the external service’s latency directly impacts your login latency. Use short timeouts on fetch() calls within actions, and consider whether the check can be done asynchronously (post-login instead of pre-login) if it is not security-critical.
The Blueprint Visual Editor
For teams that prefer visual programming over writing JavaScript, Auris provides a Blueprint Editor — a node-based visual editor inspired by Unreal Engine’s Blueprint system.
How It Works
The Blueprint Editor presents actions as a directed graph:
Node Types
| Node Type | Visual Appearance | JavaScript Equivalent |
|---|---|---|
| Trigger | Orange node | Function entry point |
| Condition | Cyan node | if (field operator value) |
| Logic Gate | Violet node | && (AND) or ` |
| Deny Action | Pink node | return { allow: false, reason: '...' } |
| Set Claims | Blue node | return { claims: { key: value } } |
| Set Metadata | Emerald node | return { metadata: { key: value } } |
| Log | Green node | context.console.log('...') |
Bidirectional Serialization
The Blueprint Editor maintains a bidirectional mapping between the visual graph and JavaScript code:
- Graph to Code (
graphToJsonRule): Walks the node graph and generates equivalent JavaScript - Code to Graph (
jsonRuleToGraph): Parses a JSON rule structure and lays out nodes with auto-layout
This means you can:
- Build an action visually in the Blueprint Editor
- Switch to the code editor to see (and modify) the generated JavaScript
- Switch back to the Blueprint Editor to see your code changes reflected visually
Not all JavaScript is representable in the visual editor. Complex logic (loops, recursion, async patterns) falls back to code-only editing. The Blueprint Editor targets the most common 80% of use cases: conditional checks and claim/metadata assignments.
Design Trade-Offs
Function Constructor vs V8 Isolates
The most significant architectural decision in the Actions Engine is using the Function constructor instead of V8 Isolates (used by Auth0, Cloudflare Workers, and Deno Deploy).
| Dimension | Function Constructor (Auris) | V8 Isolates |
|---|---|---|
| Isolation level | Scope-based (same process) | Process-based (separate V8 instance) |
| Memory isolation | Shared heap | Separate heap per isolate |
| CPU isolation | Shared event loop | Separate event loop (or time-sliced) |
| Startup latency | <1ms | 5-50ms (isolate creation) |
| Native dependencies | None | Requires isolated-vm or Deno runtime |
| Suitable for | Trusted tenant code | Untrusted user code |
| Attack surface | Higher (same process) | Lower (process boundary) |
Auris chose the Function constructor because:
- Tenants are trusted: Auris tenants are paying customers who have signed terms of service. They are not anonymous users running arbitrary code.
- Performance: Sub-millisecond construction is critical for not adding latency to authentication flows.
- Simplicity: No native modules, no compilation step, no separate runtime to manage.
- Portability: Works identically in Node.js, Bun, and Edge runtimes.
The trade-off is that a sufficiently creative attacker with access to the Action editor could potentially escape the sandbox via JavaScript prototype pollution or other advanced techniques. This is mitigated by:
- The static analysis layer (catches obvious escapes)
- The restricted scope (no
process,require, orglobalavailable) - Tenant isolation (each tenant’s actions only run in their authentication flows)
- The Action editor itself requires admin-level permissions
Synchronous vs Asynchronous Execution
Actions execute synchronously within the authentication flow. This means:
- Pre-login actions block the login response. If an action takes 3 seconds, the login takes 3 extra seconds.
- Post-login actions block token issuance. Claims added by actions must be resolved before the JWT is signed.
The alternative — asynchronous execution via a job queue — would decouple actions from the auth flow but would mean:
- Pre-login actions could not block login (defeating their purpose)
- Post-login actions could not add claims to the JWT (the JWT is already signed and returned)
Synchronous execution keeps the authentication flow atomic: either all actions complete and the flow proceeds, or an action blocks and the flow stops. This is the correct model for security-critical hooks.
Fail-Secure vs Fail-Open
As discussed earlier, Auris defaults to fail-secure: if an action throws an unhandled exception, the authentication flow is blocked. The reasoning:
- Fail-open risk: A buggy action silently stops enforcing a security check. The admin does not notice until a breach occurs.
- Fail-secure risk: A buggy action blocks all logins. The admin notices immediately because users cannot log in.
Between “users cannot log in” and “security check is silently bypassed,” the former is the safer default. Auris makes it easy to disable a failing action from the Console, and action errors are prominently displayed in the dashboard.
Code Example: Common Action Patterns
Block Disposable Email Providers
// Trigger: pre_signup
// Block signups from disposable email providers
const disposableDomains = [
'tempmail.com', 'throwaway.email', 'guerrillamail.com',
'mailinator.com', '10minutemail.com', 'yopmail.com'
]
const domain = context.user.email.split('@')[1]
if (disposableDomains.includes(domain)) {
return {
allow: false,
reason: 'Disposable email addresses are not allowed.'
}
}
return { allow: true }Enrich Token with External Data
// Trigger: post_login
// Add department and manager from HR system
try {
const response = await context.fetch(
'https://hr.internal/api/employee?email=' +
encodeURIComponent(context.user.email),
{ headers: { 'X-Api-Key': 'hr-api-key' } }
)
if (response.ok) {
const employee = await response.json()
return {
claims: {
department: employee.department,
manager_id: employee.managerId,
cost_center: employee.costCenter
}
}
}
} catch (err) {
context.console.error('HR API call failed:', err.message)
}
// If the HR API is down, do not block login — just skip enrichment
return {}Geo-Restrict Login
// Trigger: pre_login
// Only allow login from specific countries
const allowedCountries = ['US', 'CA', 'GB', 'DE', 'FR', 'IT', 'ES']
const country = context.request.geoip?.country
if (country && !allowedCountries.includes(country)) {
context.console.warn(
'Login blocked from', country, 'for user', context.user.email
)
return {
allow: false,
reason: 'Login is not available from your current location.'
}
}
return { allow: true }Related Concepts
- OAuth 2.0 & OIDC — The authentication flows that Actions hook into
- Tokens Explained — How custom claims from Actions appear in JWTs
- Adaptive MFA & Risk Scoring — Risk scoring can trigger step-up MFA, complementing Actions
- Multi-Tenancy — How tenant isolation applies to Actions