Source: docs/cookbook-interceptors.md · Edit on GitHub

Cookbook: interceptors, auth, TLS / mTLS

Logging interceptor

let sink ← IO.mkRef (#[] : Array String)
let chain : Array Grpc.Interceptor.ServerUnary := #[Grpc.Interceptor.loggingServer sink]
let mut s := Grpc.Server.empty
s := Grpc.Interceptor.registerUnary s "demo.Svc" "Ping" chain fun req => do
  pure (req, .ok)

Client side:

let res ← Grpc.Interceptor.callUnary ch "demo.Svc" "Ping"
  #[Grpc.Interceptor.loggingClient sink] reqBytes

Bearer token (call credentials)

ClientUnary only wraps the request body, so attach auth metadata on the call (or via dial call-creds):

let md := Grpc.Interceptor.bearerMetadata "my-token"
let res ← Grpc.Channel.unary ch "svc" "Method" req md

-- Or dial-wide:
let ch ← Grpc.Channel.dial "127.0.0.1:50051" {
  call := some (Grpc.Credentials.CallCredentials.accessToken "my-token")
}

Deadlines

-- 100 milliseconds
let res ← Grpc.Channel.unary ch "svc" "Slow" req {} (some "100m")
-- 2 seconds
let res ← Grpc.Channel.unary ch "svc" "Slow" req {} (some "2S")

On deadline expiry the client maps to DEADLINE_EXCEEDED (and may RST the stream).

TLS dial

let ch ← Grpc.Channel.dial "api.example.com:443" {
  channel := .tls {
    caPath := some "/etc/ssl/certs/ca-certificates.crt"
    serverName := some "api.example.com"
  }
}

mTLS → read ctx.peerIdentity in the handler

Client presents a certificate; server requires and verifies one, then exposes the verified peer identity to unary handlers via ServerCallContext:

-- Client
let ch ← Grpc.Channel.dial "localhost:50051" {
  channel := .tls {
    caPath := some "certs/ca.pem"
    certPath := some "certs/client.pem"
    keyPath := some "certs/client.key"
    serverName := some "localhost"
  }
}

-- Server
let mut s := Grpc.Server.empty
s := Grpc.Server.registerWithContext s "demo.Svc" "Ping" fun ctx req => do
  match ctx.peerIdentity with
  | none =>
      -- Under mTLS (`clientCaPath` set) this should be unreachable after a
      -- successful handshake; treat as miswire / UNAUTHENTICATED.
      pure (ByteArray.empty, .unauthenticated "mtls_required")
  | some id =>
      -- Map id.uriSans / id.commonName → principal via local policy.
      -- Subject DN is OpenSSL RFC 2253 form.
      pure (req, .ok)

-- Optional interceptor that fails closed when mTLS was configured but identity is missing:
s := Grpc.Interceptor.registerUnaryWithContext s "demo.Svc" "Secure"
  #[Grpc.Interceptor.requirePeerIdentity] fun ctx req => do
    pure (req, .ok)

Grpc.Server.serveTls s {
  certPath := some "certs/server.pem"
  keyPath := some "certs/server.key"
  clientCaPath := some "certs/ca.pem"
} { host := "127.0.0.1", port := 50051 }

Do not treat inbound metadata such as x-client-subject, x-forwarded-client-cert, or a self-declared principalId in the request body as AuthN. Those values are attacker-controlled unless you terminate TLS at a trusted proxy and document a separate trusted-proxy identity mode (not provided by lean-grpc today). Identity fields are only as trustworthy as the clientCaPath private CA / PKI.

End-to-end coverage: tlsLoopback (plain TLS, reject without client cert, accept with client cert, dual-cert identity binding, metadata non-forgery, h2c → none, accept-loop survival after failed handshake).

Working example: Examples/MirrorForge/Stamp uses registerUnaryWithContext (Bearer metadata preferred, body token fallback). Set TLS_CERT/TLS_KEY/ TLS_CLIENT_CA on the server and TLS_CA (+ client cert) on the client for mTLS; see Examples/MirrorForge/README.md.

Streaming handlers do not yet receive ServerCallContext (follow-on).

See also

  • TLS / Envoy
  • Tests/OpsSmoke.lean — interceptor + health/reflection/channelz smoke
  • Tests/TlsLoopback.lean — mTLS + peer-identity loopback

Source: docs/cookbook-interceptors.md in RileyBetts/lean-grpc. Hosted docs may lag the repository slightly.