Skip to Content
ConceptsActions & Sandboxed Execution

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:

ApproachProblem
Feature requestsSlow, blocks customers, every use case is different
Webhooks to external servicesAdds latency, requires customer to host a webhook server, cannot block the flow synchronously
Fork the platformMaintenance nightmare, blocks upgrades
Configuration-only rulesToo 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:

ProviderMechanismLanguageExecution ModelLatency Impact
Auth0ActionsJavaScript/TypeScriptIsolated VM (Deno-like sandbox)Medium (VM startup)
OktaHooks (Inline/Event)N/A (webhook)External HTTP call to customer serverHigh (network round-trip)
FirebaseExtensionsTypeScriptCloud Functions (separate process)High (cold start + execution)
KeycloakSPIs (Service Provider Interfaces)JavaClasspath plugin, loaded at bootLow (same process)
WorkOSWebhooks + RulesN/AExternal HTTP + declarative rulesVaries
AurisActionsJavaScriptSandboxed Function constructorLow (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:

TriggerWhen It FiresCan Block?Can Enrich Token?
pre_loginAfter credentials are validated, before session creationYesNo
post_loginAfter successful login, before token issuanceNoYes (add claims)
pre_signupAfter signup form validation, before user creationYesNo
post_signupAfter user is created in the databaseNoYes (add metadata)
post_change_passwordAfter password change completesNoNo
pre_m2m_tokenBefore issuing an M2M token via client_credentialsYesYes (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

CapabilityHow
Make outbound HTTP callsawait context.fetch('https://api.example.com/check', { ... })
Read the current user’s profilecontext.user.email, context.user.roles
Read request metadatacontext.request.ip, context.request.userAgent
Block authenticationreturn { allow: false, reason: 'Blocked by policy' }
Add JWT claimsreturn { claims: { department: 'engineering' } }
Add user metadatareturn { metadata: { onboardingStep: 3 } }
Log messagescontext.console.log('Checked IP:', context.request.ip)
Use JavaScript built-insJSON.parse(), Date.now(), Math.random(), Array.from(), etc.

What Actions CANNOT Do

RestrictionWhy
Load modules (require, import)Prevents access to Node.js APIs (fs, child_process, net, etc.)
Access processPrevents reading env vars (process.env.DATABASE_URL), exiting the server (process.exit())
Access global / globalThisPrevents escaping the sandbox scope
Create new execution contextsNested dynamic code construction is blocked by static analysis
Access the filesystemNo fs module available; fetch is the only I/O mechanism
Access the databaseNo Prisma client or database connection in scope
Access other tenants’ datacontext only contains data for the current tenant and user
Run indefinitelyPromise.race() timeout enforced (default 5 seconds)
Write to stdout/stderrconsole 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:

OperationTypical LatencyNotes
Static analysis (blocked patterns)<0.1msSimple string search
Function construction~0.5msOne-time per execution (not cached, for security)
Simple logic (no HTTP)1-5msConditionals, string operations, JSON manipulation
With one outbound HTTP call50-500msDominated by external service latency
With multiple HTTP callsSum of callsSequential by default; can use Promise.all() for parallel
Timeout5,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 TypeVisual AppearanceJavaScript Equivalent
TriggerOrange nodeFunction entry point
ConditionCyan nodeif (field operator value)
Logic GateViolet node&& (AND) or `
Deny ActionPink nodereturn { allow: false, reason: '...' }
Set ClaimsBlue nodereturn { claims: { key: value } }
Set MetadataEmerald nodereturn { metadata: { key: value } }
LogGreen nodecontext.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:

  1. Build an action visually in the Blueprint Editor
  2. Switch to the code editor to see (and modify) the generated JavaScript
  3. 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).

DimensionFunction Constructor (Auris)V8 Isolates
Isolation levelScope-based (same process)Process-based (separate V8 instance)
Memory isolationShared heapSeparate heap per isolate
CPU isolationShared event loopSeparate event loop (or time-sliced)
Startup latency<1ms5-50ms (isolate creation)
Native dependenciesNoneRequires isolated-vm or Deno runtime
Suitable forTrusted tenant codeUntrusted user code
Attack surfaceHigher (same process)Lower (process boundary)

Auris chose the Function constructor because:

  1. Tenants are trusted: Auris tenants are paying customers who have signed terms of service. They are not anonymous users running arbitrary code.
  2. Performance: Sub-millisecond construction is critical for not adding latency to authentication flows.
  3. Simplicity: No native modules, no compilation step, no separate runtime to manage.
  4. 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, or global available)
  • 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 }