The OPC UA REST binding exposes the OPC UA service set as ASP.NET Core Minimal-API endpoints, implementing the OpenAPI Mapping defined in OPC UA Part 6 §G.3 (v1.05.07).
The binding ships as part of
OPCFoundation.NetStandard.Opc.Ua.Bindings.Https (net8+ only;
mounted on the same Kestrel host as the binary and
application/opcua+uajson sub-profiles).
-
Server side: ASP.NET Core Minimal-API endpoints (one
MapPostper spec service) — NativeAOT-compatible; no MVC reflection, no[UnconditionalSuppressMessage]attributes. -
Client side: symmetric
IWebApiClientinOPCFoundation.NetStandard.Opc.Ua.Clientundersrc/Opc.Ua.Client/WebApi/.
using Microsoft.Extensions.DependencyInjection;
using Opc.Ua;
using Opc.Ua.Bindings;
services
.AddOpcUa()
.AddHttpsTransport() // existing binary + opcua+uajson
.AddWebApiTransport() // adds REST routes alongside them
.AddWebApiAnonymousAuth(); // or AddWebApiBearerAuth(...) etc.The REST surface mounts inside the existing HttpsTransportListener
Kestrel pipeline, so a single port serves binary,
application/opcua+uajson, and REST traffic without configuring a
second listener.
A spec-shaped client request:
curl -X POST https://server:4843/read \
-H 'Content-Type: application/json' \
--data '{
"RequestHeader": { "TimeoutHint": 10000 },
"TimestampsToReturn": 2,
"NodesToRead": [
{ "NodeId": "i=2258", "AttributeId": 13 }
]
}'returns the matching ReadResponse body. To send a Verbose JSON request body,
use Content-Type: application/json; encoding=verbose. To request a Verbose
JSON response, use Accept: application/json; encoding=verbose.
The binding implements every service in the spec's
opc.ua.openapi.allservices.json
document — 28 services across the Discovery, Session, View, Attribute,
Method, MonitoredItem, and Subscription service sets. NodeManagement
(Part 4 §5.8) and Query (§5.10) are not in scope — the spec
document deliberately omits them.
| Service set (Part 4) | Routes |
|---|---|
| Discovery (§5.5) | /findservers, /getendpoints |
| Session (§5.7) | /createsession, /activatesession, /closesession, /cancel |
| View (§5.9) | /browse, /browsenext, /translate, /registernodes, /unregisternodes |
| Attribute (§5.11) | /read, /write, /historyread, /historyupdate |
| Method (§5.12) | /call |
| MonitoredItem (§5.13) | /createmonitoreditems, /modifymonitoreditems, /setmonitoringmode, /settriggering, /deletemonitoreditems |
| Subscription (§5.14) | /createsubscription, /modifysubscription, /setpublishingmode, /publish, /republish, /transfersubscriptions, /deletesubscriptions |
All routes are POST with a JSON body holding the matching
<Service>Request; the response body is the matching
<Service>Response. The route table is the source of truth — see
WebApiServiceRoutes in src/Opc.Ua.Core/Stack/WebApi/.
WebApiTransportOptions.ServiceSet selects the services the binding maps:
WebApiServiceSet |
Routes |
|---|---|
AllServices (default) |
all 28 |
Sessionless |
/read, /write, /historyread, /historyupdate, /call, /browse, /browsenext, /translate (the services a client can call without a session, OPC 10000-4 §6.3) |
Sessionless leaves discovery and session management to the binary and
opcua+uajson endpoints of the same listener.
The binding can describe the routes it maps as an OpenAPI 3.0 document. The
route is off by default; set OpenApiDocumentPath to serve the document
of the selected service set with GET. The route belongs to the REST
routes and requires the same authentication and authorization as they do
(only /findservers and /getendpoints are anonymous).
services.AddOpcUa()
.AddHttpsTransport()
.AddWebApiTransport(opt =>
{
opt.ServiceSet = WebApiServiceSet.Sessionless;
opt.OpenApiDocumentPath = "/openapi.json"; // default null: not served
opt.OpenApiIncludeSchemas = true; // default false
});The document is generated from the code, not copied from the
publication: WebApiOpenApiGenerator (package Opc.Ua.Core.Schema) builds
it from WebApiServiceRoutes and the structure definitions of the request
and response types, so it describes what the binding actually accepts and
sends. Without OpenApiIncludeSchemas each body is a JSON object and the
document is about 12 KB; with it the document has one component schema per
request, response and contained structure and enumeration (about 46 KB for
all services). An OpenAPI client generator needs these schemas to emit typed
models. A conformance test compares the generated document with the ones
the OPC Foundation publishes in
UA-Nodeset/OpenApi;
the one difference in the contract is the status field of a DataValue
(Status per Part 6 Table 42, StatusCode in the publication,
UA-Nodeset#146).
Hosts that compose their own pipeline call
endpoints.MapWebApiEndpoints(options); the document route joins the
returned group, so group.RequireAuthorization() covers it as well. The
OPC UA over OpenAPI guide covers generating clients, the JSON
generated clients send, their known issues and how the document is kept
correct.
OPC UA Part 6 §5.4 defines two JSON flavours: Compact (default,
mandatory) and Verbose. The REST binding selects between them via
the encoding media-type parameter:
Request Content-Type |
Server response Content-Type |
|---|---|
application/json |
application/json; encoding=compact |
application/json; encoding=compact |
application/json; encoding=compact |
application/json; encoding=verbose |
application/json; encoding=verbose |
application/json; encoding=verbose + Accept: application/json; encoding=compact |
application/json; encoding=compact |
The Accept header takes precedence for the response encoding; the
Content-Type header governs how the inbound body is decoded. Unknown
encoding parameter values fall back to Compact.
When the binding is registered, the HttpsServiceHost emits a
discovery-only twin per SecurityMode.None HTTPS-binary endpoint with:
TransportProfileUri = http://opcfoundation.org/UA-Profile/Transport/https-uajson-openapi(OPC Foundation profile/2338; surfaced viaProfiles.HttpsOpenApiTransport)SecurityMode = None(TLS provides transport security)SecurityPolicyUri = NoneServerCertificateandUserIdentityTokenscopied from the companion HTTPS-binary description so clients can pick a compatible identity at activate time.
Clients discover the binding through GetEndpoints and resolve it by
the TransportProfileUri — the synthetic registry-key scheme
opc.https+webapi (Utils.UriSchemeOpcHttpsWebApi) maps the profile
to the WebApiTransportChannelFactory while the wire-level URL stays
https://.... The fluent shortcut
ManagedSessionBuilder.UseWebApiEndpoint(url) constructs the
endpoint description with all of these fields pre-populated.
The companion WebSocket sub-profile
Profiles.WssOpenApiTransport(OPC Foundation profile/2339) is implemented as a peer to the HTTPS surface:WebApiWssTransportChannelon the client side,HttpsTransportListener.AcceptWebSocketOpenApiAsyncon the server side. Fluent shortcut:ManagedSessionBuilder.UseWssOpenApiEndpoint(url, encoding). The bearer token is encoded in the selected sub-protocol name (opcua+openapi+<accesstoken>) because browser WebSocket APIs forbid custom HTTP request headers.
Security considerations for the bearer-prefix sub-protocol: the WebSocket spec requires the server to echo the selected sub-protocol back to the client in the 101 handshake. The bearer token therefore appears in plain text in:
- The 101 response on the wire (rejected by the client when the URL scheme is not
wss://; rejected by the server when the request is notIsHttps).- The server's Kestrel access log (
Sec-WebSocket-Protocolheader).- Any HTTP proxy / WAF / load-balancer log on the path.
Operators must redact the
Sec-WebSocket-Protocolheader from logs and should use short-lived tokens (≤ 60 s TTL) so a captured token expires before it can be replayed. The server also requiresAddWebApiBearerAuth(...)to be registered: without a bearer validator the listener fail-closed rejects every bearer-prefix upgrade rather than echo the token back.
The persistent WSS client multiplexes requests with connection-local wire handles while preserving caller-visible handles. See Transports for cancellation, late-response routing and connection-lifetime behavior.
The body is the bare <Service>Request / <Service>Response object —
no {UaTypeId, UaBody} envelope at the HTTPS layer. Body
serialization uses the stack's existing JsonEncoder /
JsonDecoder driven by WebApiBodyCodec. The encoder shape matches
the spec's component schemas property-for-property; see
WebApiEncoderConformanceTests in
tests/Opc.Ua.Core.Tests/Stack/WebApi/.
Four auth modes are pluggable via DI:
services.AddOpcUa()
.AddHttpsTransport()
.AddWebApiTransport()
.AddWebApiAnonymousAuth() // baseline
.AddWebApiBearerAuth(opt => { opt.Authority = "..."; }) // JWT
.AddWebApiBasicAuth(async (user, password) => { /* ... */ }) // RFC 7617
.AddWebApiMutualTlsAuth(opt => { opt.AllowedCertificateTypes = ... });- Anonymous — no
Authorizationheader; identity =AnonymousIdentityToken. - Bearer JWT — standard
Microsoft.AspNetCore.Authentication.JwtBearermiddleware; the JWT flows into the OPC UA dispatcher throughISessionlessIdentityProvider. - HTTP Basic — in-package
BasicAuthenticationHandler. Rejects requests over plain HTTP by default (Options.RequireHttps = true). - Mutual TLS —
Microsoft.AspNetCore.Authentication.Certificateagainst the client cert presented to Kestrel. Kestrel itself must be configured to request client certificates (the existingHttpsSettings.HttpsMutualTlsflag is the supported path).
For sessionless services (Read/Write/Browse/…) the resolved
IUserIdentity is attached to the
WebApiInvocationContext.Identity and flows into the
dispatcher's role-based-access pipeline through
ISessionlessIdentityProvider. For session-based services the
client is responsible for calling CreateSession + ActivateSession
with credentials in the request body — the binding does not
double-authenticate.
The WebApiHttpsStartupContributor automatically inserts
app.UseAuthentication() between UseRouting() and UseEndpoints()
whenever at least one auth scheme is registered (detected via
IOptions<AuthenticationOptions>.Schemes), so the
HttpContext.User is populated before the dispatcher invokes the
sessionless identity provider. Bindings registered without any
AddWebApi*Auth() call skip the authentication middleware entirely
to preserve the historical anonymous request flow.
The default ISessionlessIdentityProvider is intentionally
conservative; register a custom implementation to map richer
claim sets (e.g. JWT role claims → OPC UA roles).
For services configured with Microsoft.AspNetCore.Authentication.JwtBearer,
the binding ships a built-in identity provider that projects the
authenticated JWT principal onto an OPC UA IUserIdentity:
services.AddOpcUa()
.AddHttpsTransport()
.AddWebApiTransport()
.AddWebApiBearerAuth(opt => { opt.Authority = "..."; })
.UseJwtClaimIdentityProvider(opt =>
{
opt.SubjectClaim = "sub"; // default
opt.ScopeClaim = "scope"; // default — space- or comma-separated
opt.RolesClaim = "roles"; // default — also picks up ClaimTypes.Role
opt.ReturnAnonymousForUnauthenticated = false; // default
opt.TransformIdentity = (identity, ctx) => identity; // optional hook
});The provider builds a UserIdentity carrying the raw bearer token as
an IssuedIdentityToken (Profiles.JwtUserToken) so server-side
authenticators can re-validate the JWT downstream, and exposes a
projection of the subject / scopes / roles via the static helpers
JwtClaimSessionlessIdentityProvider.GetSubject(IUserIdentity),
GetScopes(IUserIdentity), and GetRoles(IUserIdentity) for
role-based-access lookups. Scopes accept either the single
space-separated OAuth 2.0 form or repeated scope claims; roles
accept comma/semicolon/space-separated values plus
ClaimTypes.Role mappings.
services.AddWebApiTransport(opt =>
{
opt.HostingMode = WebApiHostingMode.SharedWithHttpsListener; // default
opt.DefaultEncoding = WebApiEncoding.Compact; // spec default
});SharedWithHttpsListener(default) — Minimal-API endpoints mount into the existingHttpsTransportListenerKestrel pipeline via the internalIHttpsListenerStartupContributorhook. Single port for binary /opcua+uajson/ REST.
PublishRequest/PublishResponse flow through the same dispatcher
the binary path uses. The server side awaits the next notification
(or the request's RequestHeader.TimeoutHint) without blocking a
thread. Kestrel timeouts must exceed the largest expected
TimeoutHint:
webHostBuilder.UseKestrel(o =>
{
o.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(5);
o.Limits.RequestBodyTimeout = TimeSpan.FromMinutes(5);
});On the client, set WebApiClientOptions.RequestTimeout = Timeout.InfiniteTimeSpan for the publish endpoint so the server-side
TimeoutHint governs cancellation.
The OPC Foundation publishes reference WebApi clients for several ecosystems:
- TypeScript: https://github.com/OPCFoundation/opcua-webapi-typescript
- Python: https://github.com/OPCFoundation/opcua-webapi-python
- .NET: https://github.com/OPCFoundation/opcua-webapi-dotnet
On the .NET stack this binding's REST surface is wired into
ManagedSession via the fluent
ManagedSessionBuilder.UseWebApiEndpoint(url, encoding) shortcut, so
applications open and operate a WebApi session through the same
ISession / subscription / monitored-item API that the binary
transport uses:
using Opc.Ua.Client;
await using ManagedSession session = await new ManagedSessionBuilder(telemetry)
.UseWebApiEndpoint("https://server:4843/")
.WithSessionName("Aot-Sample-Client")
.WithUserIdentity(new UserIdentity())
.StartAsync(ct)
.ConfigureAwait(false);
ReadResponse response = await session.ReadAsync(new ReadRequest
{
RequestHeader = new RequestHeader { TimeoutHint = 10000 },
NodesToRead = new ArrayOf<ReadValueId>(/* … */)
}, ct).ConfigureAwait(false);The companion UseWssOpenApiEndpoint(url) shortcut binds the same
session model to the WebSocket opcua+openapi sub-protocol.
Errors of a processed request come back in the OPC UA response
(ResponseHeader.ServiceResult, HTTP 200). An HTTP error status alone
does not tell whether the service ran. The WebApi server of this stack
sends 400 (undecodable body), 401 and 403 (authentication and
authorization), 404 and 405 (no matching route), 413 (request body over
the limit) and 429 (rate limiting) before it invokes the service. For
any other status the outcome is unknown: the server answers 500 when it
cannot encode the response of a service that already ran, and a proxy
or gateway can answer 502, 503 or 504 after the server executed the
request. A status from a proxy or another server implementation tells
only what that component reports. Retry a service that changes state,
such as Write or Call, only when repeating it is harmless.
WebApiClient (and therefore ManagedSession over
UseWebApiEndpoint) reports the HTTP error as a
ServiceResultException:
| HTTP | StatusCode |
|---|---|
| 400 | Bad_DecodingError (the server could not decode the body) |
| 401 | Bad_IdentityTokenInvalid when the client sent no credentials, Bad_UserAccessDenied when it did |
| 403 | Bad_UserAccessDenied |
| 404, 405, 501 | Bad_ServiceUnsupported (the route is not mapped) |
| 408, 504 | Bad_Timeout |
| 413 | Bad_RequestTooLarge |
| 415 | Bad_DataEncodingUnsupported |
| 429, 503 | Bad_ServerTooBusy, with a Retry-After header as RetryAfterMs=<n> in AdditionalInfo |
| 500 | Bad_InternalError |
| any other | Bad_CommunicationError |
The message names the HTTP status, the reason phrase and the route, and
the inner exception is an HttpRequestException (on .NET 5 and later
with its StatusCode set). An elapsed RequestTimeout or
HttpClient.Timeout is Bad_RequestTimeout. A request that fails below
HTTP (no connection, failed TLS handshake) gets the StatusCode the HTTPS
transport channel reports for the same failure, for example
Bad_NotConnected for a refused connection. Cancelling the caller's
token still throws OperationCanceledException.
OPC 10000-6 §7.4.5 lets JSON messages be compressed with gzip (IETF RFC
1952), announced by Content-Encoding: gzip. On the client,
WebApiClientOptions.AcceptCompressedResponses = true sends
Accept-Encoding: gzip (on a shared HttpClient per request, without
changing its default headers):
using WebApiClient client = WebApiClient.Create(
new Uri("https://server:4843/"),
new WebApiClientOptions { AcceptCompressedResponses = true });A gzip response is inflated whether it was asked for or not. The
MaxMessageSize limit applies to the inflated body, a corrupt gzip body
is Bad_DecodingError, and a content coding other than gzip or identity
is rejected with Bad_DecodingError. The HTTPS binary channel reads its
responses through the same code.