Security
Guide to securing Orleans.FSharp deployments.
Security
Section titled “Security”Guide to securing Orleans.FSharp deployments.
What you’ll learn
Section titled “What you’ll learn”- 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
TLS Encryption
Section titled “TLS Encryption”Orleans silo-to-silo and client-to-silo communication can be encrypted with TLS. Requires the Microsoft.Orleans.Connections.Security NuGet package.
Silo TLS
Section titled “Silo TLS”open Orleans.FSharp.Runtime
// By certificate subject name (from certificate store)let config = siloConfig { addRedisClustering redisConn useTls "CN=my-silo-cert"}
// By certificate instancelet cert = new X509Certificate2("silo-cert.pfx", certPassword)
let config = siloConfig { addRedisClustering redisConn useTlsWithCertificate cert}Client TLS
Section titled “Client TLS”let config = clientConfig { useStaticClustering [ "10.0.0.1:30000" ] useTls "CN=my-client-cert"}Mutual TLS (mTLS)
Section titled “Mutual TLS (mTLS)”Mutual TLS requires both the server and client to present certificates. This provides stronger authentication than one-way TLS.
Silo mTLS
Section titled “Silo mTLS”let config = siloConfig { addRedisClustering redisConn useMutualTls "CN=my-silo-cert"}
// Or with a certificate instancelet config = siloConfig { addRedisClustering redisConn useMutualTlsWithCertificate cert}Client mTLS
Section titled “Client mTLS”let config = clientConfig { useStaticClustering [ "10.0.0.1:30000" ] useMutualTls "CN=my-client-cert"}Production guidelines
Section titled “Production guidelines”- 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
Section titled “Call Filters”Call filters intercept grain calls for authorization, logging, rate limiting, and more.
Incoming filter for authorization
Section titled “Incoming filter for authorization”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}Outgoing filter for context propagation
Section titled “Outgoing filter for context propagation”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() })Before/after pattern
Section titled “Before/after pattern”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 |] })FilterContext helpers
Section titled “FilterContext helpers”| Function | Returns | Description |
|---|---|---|
FilterContext.methodName ctx | string | The method name being called |
FilterContext.interfaceType ctx | Type | The grain interface type |
FilterContext.grainInstance ctx | obj option | The grain instance if available |
A filter sees a functional grain call as an ordinary Orleans call, but
FilterContext.methodName reports the transport’s own method rather than your operation. To
authorize per operation there, match argument 0 against IFunctionalRequestMetadata — see
Functional Grain Runtime, “Call filters over a functional grain”.
Request Context
Section titled “Request Context”RequestCtx propagates key-value pairs across grain calls automatically. Values set on the caller side are available on the callee side.
Set and get values
Section titled “Set and get values”open Orleans.FSharp
// On the caller sideRequestCtx.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" // NoneGet with default
Section titled “Get with default”let role = RequestCtx.getOrDefault<string> "Role" "anonymous"Remove values
Section titled “Remove values”RequestCtx.remove "UserId"Scoped values
Section titled “Scoped values”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 completesConnection String Security
Section titled “Connection String Security”Never hardcode connection strings or secrets in source code.
Use environment variables
Section titled “Use environment variables”let redisConn = Environment.GetEnvironmentVariable("REDIS_CONNECTION")let azureConn = Environment.GetEnvironmentVariable("AZURE_STORAGE_CONNECTION")
let config = siloConfig { addRedisClustering redisConn addRedisStorage "Default" redisConn}Use IConfiguration
Section titled “Use IConfiguration”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 ())}What to avoid
Section titled “What to avoid”// DO NOT do this -- secrets will leak into version controladdRedisStorage "Default" "redis://user:password@host:6379"addAzureBlobStorage "Default" "DefaultEndpointsProtocol=https;AccountName=..."Secure Principal Propagation Pattern
Section titled “Secure Principal Propagation Pattern”A common security pattern is to set the user principal at the entry point and validate it in grain call filters:
open System.Security.Claimsopen System.Threading.Tasksopen Microsoft.AspNetCore.Httpopen Orleans.FSharp
// At the API boundary (ASP.NET controller, etc.)let handleRequest (httpContext: HttpContext) (doWork: unit -> Task<'T>) = task { let userId = httpContext.User.Identity.Name let roles = httpContext.User.Claims |> Seq.filter (fun claim -> claim.Type = ClaimTypes.Role) |> Seq.map (fun claim -> claim.Value) |> Seq.toArray
RequestCtx.set "Principal" (box userId) RequestCtx.set "Roles" (box roles)
// All grain calls from here will carry the principal let! result = doWork () return result }
// In a grain call filterlet 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" })Logging Security
Section titled “Logging Security”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 fieldsLog.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 THISUse 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 })Orleans Dashboard Security
Section titled “Orleans Dashboard Security”⚠️ 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.
Production guidelines
Section titled “Production guidelines”- Do not enable the dashboard in production unless you need it, OR
- Place it behind a reverse proxy with authentication (nginx + basic auth, Cloudflare Access, etc.), OR
- Restrict network access to the dashboard port using firewall rules or security groups
// Dashboard is opt-in — only add it when you need itlet config = siloConfig { addDashboard // requires Microsoft.Orleans.Dashboard package // NOT recommended for production without additional access controls}FSharpBinaryCodec Trust Model
Section titled “FSharpBinaryCodec Trust Model”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.
Trust boundary
Section titled “Trust boundary”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
What the codec already refuses
Section titled “What the codec already refuses”A wire-supplied type name is checked before any assembly load, not after resolution:
- An assembly allow-list, matched on whole dotted segments. Only
Orleans.FSharp,System,Microsoft.FSharp,FSharp.Core,mscorlib,netstandardandTypeShape(and namespaces beneath them) pass — soOrleans.FSharpHostileis refused despite the shared prefix. The check covers a generic’s arguments as well as its own qualifier, becauseType.GetTypeloads every one of them. - A cap on distinct wire-resolved type names (512 per process), so a stream of unique names cannot grow the type and codec caches without bound. A rejected name is never cached.
- A length cap on the type name itself, which also bounds the qualifier scan’s nesting depth.
- Bounds checks on every wire-supplied length, element count, union case tag, and record or POCO arity, against the bytes actually remaining and the real shape of the target type.
A rejection is an InvalidOperationException naming the offending assembly or field, not an
IndexOutOfRangeException or an oversized allocation.
What it still does not defend against
Section titled “What it still does not defend against”The allow-list bounds which assemblies a payload can name; it does not make an arbitrary attacker-chosen graph inside those assemblies safe. Crafted bytes that clear every check above can still set your own grain state to values the application never intended. Treat the codec as in-cluster infrastructure, not as an input parser.
Mitigation
Section titled “Mitigation”At the network layer (the one that matters): Orleans 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}Do not point the codec at external input. The F# binary format has no public
decode-with-a-known-type entry point — FSharpBinaryFormat is internal — precisely because
there is no supported way to use it as a parser for bytes from outside the cluster. Parse
untrusted input with a format designed for it (System.Text.Json and the Serialization module’s
F# converters, for example), and let the codec see only what Orleans itself produced.
RequestCtx Trust Model
Section titled “RequestCtx Trust Model”RequestCtx propagates key-value pairs across grain calls using Orleans’ built-in
RequestContext mechanism. Values flow automatically from caller to callee.
Trust boundary
Section titled “Trust boundary”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.
What this means
Section titled “What this means”- ✅ 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
Authorization pattern
Section titled “Authorization pattern”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}Next steps
Section titled “Next steps”- Silo Configuration — TLS and filter configuration
- Advanced — transactions and more security patterns
- API Reference — all public security-related APIs