Skip to content

Security

Guide to securing Orleans.FSharp deployments.

Guide to securing Orleans.FSharp deployments.

  • How to configure TLS and mTLS for silo communication
  • How to use call filters for authorization
  • How to propagate security context with RequestCtx
  • How to manage connection strings and secrets safely

Orleans silo-to-silo and client-to-silo communication can be encrypted with TLS. Requires the Microsoft.Orleans.Connections.Security NuGet package.

open Orleans.FSharp.Runtime
// By certificate subject name (from certificate store)
let config = siloConfig {
addRedisClustering redisConn
useTls "CN=my-silo-cert"
}
// By certificate instance
let cert = new X509Certificate2("silo-cert.pfx", certPassword)
let config = siloConfig {
addRedisClustering redisConn
useTlsWithCertificate cert
}
let config = clientConfig {
useStaticClustering [ "10.0.0.1:30000" ]
useTls "CN=my-client-cert"
}

Mutual TLS requires both the server and client to present certificates. This provides stronger authentication than one-way TLS.

let config = siloConfig {
addRedisClustering redisConn
useMutualTls "CN=my-silo-cert"
}
// Or with a certificate instance
let config = siloConfig {
addRedisClustering redisConn
useMutualTlsWithCertificate cert
}
let config = clientConfig {
useStaticClustering [ "10.0.0.1:30000" ]
useMutualTls "CN=my-client-cert"
}
  • Always use valid certificates from a trusted certificate authority in production.
  • Never disable certificate validation in production environments.
  • Rotate certificates before they expire.
  • Use separate certificates for silos and clients in mTLS deployments.
  • Store certificate passwords in a secrets manager (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault).

Call filters intercept grain calls for authorization, logging, rate limiting, and more.

open Orleans.FSharp
let authFilter =
Filter.incoming (fun ctx ->
task {
let principal = RequestCtx.get<string> "Principal"
match principal with
| Some user ->
// Check authorization
let methodName = FilterContext.methodName ctx
if isAuthorized user methodName then
do! ctx.Invoke()
else
raise (UnauthorizedAccessException $"User {user} not authorized for {methodName}")
| None ->
raise (UnauthorizedAccessException "No principal in request context")
})
let config = siloConfig {
useLocalhostClustering
addIncomingFilter authFilter
}
let propagateContextFilter =
Filter.outgoing (fun ctx ->
task {
// Ensure the principal is propagated to downstream grain calls
let principal = RequestCtx.get<string> "Principal"
match principal with
| Some _ -> do! ctx.Invoke()
| None ->
// Set a default principal for internal calls
RequestCtx.set "Principal" (box "system")
do! ctx.Invoke()
})

Use Filter.incomingWithAround for timing, metrics, or audit logging:

let auditFilter =
Filter.incomingWithAround
(fun ctx ->
task {
let method = FilterContext.methodName ctx
let interfaceType = FilterContext.interfaceType ctx
Log.logInfo logger "Grain call started: {Interface}.{Method}"
[| box interfaceType.Name; box method |]
})
(fun ctx ->
task {
let method = FilterContext.methodName ctx
Log.logInfo logger "Grain call completed: {Method}" [| box method |]
})
FunctionReturnsDescription
FilterContext.methodName ctxstringThe method name being called
FilterContext.interfaceType ctxTypeThe grain interface type
FilterContext.grainInstance ctxobj optionThe grain instance if available

RequestCtx propagates key-value pairs across grain calls automatically. Values set on the caller side are available on the callee side.

open Orleans.FSharp
// On the caller side
RequestCtx.set "UserId" (box "user-123")
RequestCtx.set "TenantId" (box "tenant-abc")
// On the callee side (inside a grain handler)
let userId = RequestCtx.get<string> "UserId" // Some "user-123"
let tenantId = RequestCtx.get<string> "TenantId" // Some "tenant-abc"
let missing = RequestCtx.get<string> "NotSet" // None
let role = RequestCtx.getOrDefault<string> "Role" "anonymous"
RequestCtx.remove "UserId"

Set a value for the duration of an async operation, then clean up automatically:

let! result =
RequestCtx.withValue "CorrelationId" (box correlationId) (fun () ->
task {
// CorrelationId is available here and in all downstream grain calls
return! doWork()
})
// CorrelationId is automatically removed after the function completes

Never hardcode connection strings or secrets in source code.

let redisConn = Environment.GetEnvironmentVariable("REDIS_CONNECTION")
let azureConn = Environment.GetEnvironmentVariable("AZURE_STORAGE_CONNECTION")
let config = siloConfig {
addRedisClustering redisConn
addRedisStorage "Default" redisConn
}
let config = siloConfig {
configureServices (fun services ->
let sp = services.BuildServiceProvider()
let configuration = sp.GetRequiredService<IConfiguration>()
let connStr = configuration.GetConnectionString("Redis")
// Use connStr for storage setup
())
}
// DO NOT do this -- secrets will leak into version control
addRedisStorage "Default" "redis://user:password@host:6379"
addAzureBlobStorage "Default" "DefaultEndpointsProtocol=https;AccountName=..."

A common security pattern is to set the user principal at the entry point and validate it in grain call filters:

// At the API boundary (ASP.NET controller, etc.)
let handleRequest (httpContext: HttpContext) =
task {
let userId = httpContext.User.Identity.Name
RequestCtx.set "Principal" (box userId)
RequestCtx.set "Roles" (box (httpContext.User.Claims |> Seq.map ...))
// All grain calls from here will carry the principal
let! result = GrainRef.invoke myGrain (fun g -> g.DoWork())
return result
}
// In a grain call filter
let authFilter =
Filter.incoming (fun ctx ->
task {
match RequestCtx.get<string> "Principal" with
| Some principal ->
let methodName = FilterContext.methodName ctx
// Validate authorization...
do! ctx.Invoke()
| None ->
invalidOp "Unauthenticated grain call"
})

Use structured logging (the Log module) with correlation IDs. Never log sensitive data like passwords, tokens, or personal information:

open Orleans.FSharp
// Good: structured templates with safe fields
Log.logInfo logger "User {UserId} accessed grain {GrainId}" [| box userId; box grainId |]
// Bad: logging sensitive data
// Log.logInfo logger "Auth token: {Token}" [| box authToken |] // DO NOT DO THIS

Use Log.withCorrelation to scope a correlation ID across multiple log entries:

do! Log.withCorrelation requestId (fun () ->
task {
Log.logInfo logger "Processing request" [||]
// ... all logs within this scope share the correlation ID
})

⚠️ Warning: The Orleans Dashboard exposes grain state, silo metrics, and cluster topology without authentication by default. Anyone on the network who can reach the dashboard port can read all grain state.

  1. Do not enable the dashboard in production unless you need it, OR
  2. Place it behind a reverse proxy with authentication (nginx + basic auth, Cloudflare Access, etc.), OR
  3. Restrict network access to the dashboard port using firewall rules or security groups
// Dashboard is opt-in — only add it when you need it
let config = siloConfig {
addDashboard // requires Microsoft.Orleans.Dashboard package
// NOT recommended for production without additional access controls
}

FSharpBinaryCodec serializes F# types (DUs, records, options, lists, maps) without requiring [<GenerateSerializer>] or [<Id>] attributes. It embeds the type’s full name in the serialized bytes so the deserializer can recover the type at runtime.

The codec assumes that all serialized bytes come from trusted Orleans silos within the same cluster. It is NOT designed to deserialize untrusted input from:

  • External HTTP APIs
  • User-uploaded files
  • Third-party message queues outside your cluster
  • Any source that an attacker could control

If an attacker can inject crafted bytes into the Orleans message stream, they could:

  1. Embed arbitrary type names in the serialized data
  2. Force the codec to instantiate unexpected types via Type.GetType()
  3. Manipulate grain state through carefully constructed payloads

At the network layer (recommended): Orleans already encrypts silo-to-silo communication when TLS is configured. Ensure TLS is enabled in production:

let config = siloConfig {
useTls "CN=my-silo-cert" // encrypts all silo communication
}

At the codec layer (defense-in-depth): If you need to deserialize bytes from an untrusted source, use deserializeWithType with an explicit hintType parameter so the type name from the bytes is ignored:

// Safe: hintType is known at compile time, type name in bytes is ignored
let result = FSharpBinaryFormat.deserializeWithType bytes typeof<MyKnownType>

RequestCtx propagates key-value pairs across grain calls using Orleans’ built-in RequestContext mechanism. Values flow automatically from caller to callee.

RequestCtx values are only trustworthy if all grains in the cluster run trusted code. Any grain can read, modify, or forge RequestCtx values for downstream calls.

  • ✅ Safe for: correlation IDs, tracing, feature flags, non-security context
  • ⚠️ Use with caution: user identity, tenant ID, role claims
  • ❌ Never use for: authorization decisions without server-side validation

If you use RequestCtx for security context (e.g., user principal), validate it in an incoming grain call filter — don’t trust the value in the handler alone:

let authFilter = Filter.incoming (fun ctx ->
task {
match RequestCtx.get<string> "Principal" with
| Some principal ->
// Validate: is this principal authorized for this method?
let methodName = FilterContext.methodName ctx
if not (isAuthorized principal methodName) then
raise (UnauthorizedAccessException "Access denied")
do! ctx.Invoke()
| None ->
invalidOp "Unauthenticated grain call — no principal in request context"
})
let config = siloConfig {
addIncomingFilter authFilter
}