Ports, Middleware & Security
All backend generators, Go, Rust, Spring Boot, ASP.NET Core, Node.js and NestJS, read the same settings from the component spec and behave the same way at runtime. The same requests get the same status codes, headers and error bodies from all of them. The same probe tests check every generator against the same expectations. This page describes these shared settings. Each generator page lists what is specific to its language.
Differences between the generators are listed in Differences at the end of this page.
A complete example
# services/orders/.genexy/component.yaml
type: go-gnx-di # or axum, spring-boot, aspnet-core, nodejs, nestjs
exposes:
- name: public # key of the port in the configuration
protocol: http
port: 8080
contracts:
- $ref: api/orders.openapi.yaml
security: auth # the security module that checks tokens on this port
middleware:
cors: { allowedOrigins: ['https://shop.example.com'], allowCredentials: true, maxAge: 10m }
rateLimit: { limit: 100, window: 1m, key: ip }
maxRequestBytes: 1MiB
requestTimeout: 30s
securityHeaders: { hsts: true, contentSecurityPolicy: "default-src 'none'" }
compression: true
accessLog: true
validation:
unknownProperties: reject
- name: admin
port: 8081
contracts:
- $ref: api/admin.openapi.yaml
security: none # reachable only inside the cluster, no tokens
modules:
- name: auth
type: security
provider: oidc
issuer: https://sso.example.com/realms/shop
audience: orders-apiPorts
Every entry of exposes is a port with its own web server. A port serves the operations of its contracts and nothing
else: an admin operation never answers on the public port. The same contract can be served on several ports, and a
port can serve several contracts. If two contracts on one port use the same path and method, generation stops with an error.
| Key | Default | Notes |
|---|---|---|
name | api, then port2, … | Kebab case. It names the port in the configuration and in tests |
port | the next free one from 8080 | Two ports with the same number are an error |
protocol | http | Only HTTP is generated |
contracts | OpenAPI contracts: { $ref: <file> } relative to the component, or '#/contracts/<Name>' | |
security | the only security module | Name of a security module, or none |
middleware | see below | |
validation | see below |
Tests start every port on a free number and ask the application for its address. The generator pages show how in each language.
Middleware
The values in middleware are the defaults of the port. The generator writes them into the generated configuration
file, so you can still change them per environment without regenerating: in your own configuration file or with
environment variables (see Configuration). A value the generator does not understand stops the
generation with the reason. A wrong value in the configuration stops the application at startup.
| Key | Default | Behaviour |
|---|---|---|
cors | http://localhost:* and 127.0.0.1:* | allowedOrigins, allowedMethods, allowedHeaders, exposedHeaders, allowCredentials, maxAge. * matches inside one part of an origin. cors: false turns CORS off. * together with allowCredentials is refused |
rateLimit | off | Token bucket per client: limit requests per window (at least 1s), burst, key: ip or key: header:<Name>. true means 100 per minute by IP. Answers 429 with Retry-After; every response has RateLimit-Limit and RateLimit-Remaining |
maxRequestBytes | 10MiB | Larger bodies get 413. Accepts 1MiB, 512KB or a number of bytes |
requestTimeout | 60s | The deadline of the service call; 503 when it passes (not in Spring Boot, see its page) |
securityHeaders | on | X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer. Options: hsts, hstsMaxAge, frameOptions (DENY, SAMEORIGIN, none), referrerPolicy, contentSecurityPolicy. false turns them off |
compression | false | gzip for responses (not for images, media and archives) |
accessLog | false | One log line per request: method, path, status and duration |
Every request also gets an X-Request-ID: the one the caller sent, or a new one. It is returned in the response and
written into the logs; all generators but Spring Boot also put it into the problem details (requestId), and the
generated REST clients forward it.
/healthz answers 204 on every port whose contract does not define it (all generators but Spring Boot), and is never
rate limited or authenticated.
The rate limit is kept in memory, so each instance of the service counts on its own. A shared store (Redis) and
settings for single operations (x-genexy-*) are planned.
Validation
The generated code validates every request against the contract before your code runs, so the service methods
receive only valid input. The validation covers types, required properties and null, formats (date, date-time,
uuid, email, uri…), enum, const, string lengths, pattern, number ranges, array sizes, nested objects,
array items and map values. All violations of a request are returned in one 400 response:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Invalid request",
"instance": "/api/v1/orders",
"requestId": "4f1c…",
"errors": [
{ "field": "items[0].quantity", "message": "must be at least 1" },
{ "field": "bogus", "message": "is not a known property" }
]
}| Key | Values | Notes |
|---|---|---|
unknownProperties | ignore (default), reject | reject answers 400 for properties the contract does not define, also in nested objects. Objects with additionalProperties accept anything |
coerceParameters, responses, errorFormat, maxErrors and formats are planned. Today they produce a warning.
Authentication (JWT / OIDC)
A module of type security verifies the bearer tokens of the ports that use it. The contract decides what each
operation needs. You do not write any security code, and no generator adds a security framework. The tokens are
verified with the cryptography of the language (Go and Java standard library, System.Security.Cryptography,
ring in Rust, node:crypto), so the behaviour is the same everywhere.
The security module
modules:
# Keycloak or another OpenID Connect provider: the keys come from its discovery document
- name: auth
type: security
provider: oidc
issuer: https://sso.example.com/realms/shop
audience: orders-api
# A JWKS URL without discovery
- name: partners
type: security
provider: jwt
issuer: https://partners.example.com
jwksUrl: https://partners.example.com/.well-known/jwks.json
# A shared secret (HS256); keep the value out of the repository
- name: internal
type: security
provider: jwt
secret: ${INTERNAL_JWT_SECRET}| Key | Default | Notes |
|---|---|---|
provider | oidc with only an issuer, else jwt | oidc reads the key URL from <issuer>/.well-known/openid-configuration |
issuer | Required for oidc. The iss claim must match | |
jwksUrl | jwt: keys for RS256/384/512, PS256/384/512 and ES256/384/512 | |
secret | jwt: key for HS256/384/512, at least 32 characters. Write it as ${ENV_VAR}, otherwise you get a warning | |
audience | One value or a list. The aud claim, or Keycloak’s azp, must contain one of them | |
rolesClaim | realm_access.roles,roles | Claims with the roles. Use dots for nested claims and commas for several claims |
scopesClaim | scope,scp | Claims with the scopes. A space-separated string or an array |
usernameClaim | preferred_username | |
leeway | 1m | Allowed clock difference for exp and nbf |
A token is accepted when its signature is valid and its exp has not passed. exp is required. If set, nbf,
iss and aud must also match. A module with keys accepts only signed algorithms with keys, and a module with a
secret accepts only HS algorithms. A token with alg: none or with an algorithm of the other kind is refused. The
keys are cached for an hour. An unknown key id reloads them at most every 30 seconds (key rotation). When the
provider cannot be reached, the keys already loaded keep working.
exposes[].security picks the module of a port. Without it, a component with one security module uses that module on
every port. With several modules every port must name one, or none.
What an operation requires
The contract describes it, the generated code enforces it:
security: # default of every operation of the contract
- bearer: []
paths:
/orders:
get:
security:
- keycloak: [orders:read] # scopes the token must have
post:
security: # alternatives: one of them is enough
- keycloak: [orders:write]
- keycloak: [orders:admin]
/orders/{id}:
delete:
x-genexy-roles: [admin] # roles the caller must have (all of them)
/catalog:
get:
security: [] # public: no token needed
components:
securitySchemes:
bearer: { type: http, scheme: bearer, bearerFormat: JWT }
keycloak:
type: oauth2
flows:
{
clientCredentials:
{
tokenUrl: https://sso.example.com/…/token,
scopes: { orders:read: '', orders:write: '', orders:admin: '' }
}
}- Schemes of type
httpwithscheme: bearer,oauth2andopenIdConnectare checked with the bearer token. security: []or an empty requirement{}makes an operation public. A token sent anyway is still verified, and your code sees its caller.- On a port with a security module, an operation that declares nothing requires a valid token.
apiKeyandbasicschemes are not checked yet. The generator warns and leaves them out.- A port without a security module gets the warning “security requirements are not enforced” when its contracts require tokens.
| Situation | Response |
|---|---|
| No token on an operation that requires one | 401, WWW-Authenticate: Bearer |
| Invalid, expired or foreign token | 401, WWW-Authenticate: Bearer error="invalid_token", error_description=… |
Authorization that is not Bearer <token> | 401, error="invalid_request" |
| Valid token without the scopes | 403, WWW-Authenticate: Bearer error="insufficient_scope", scope=… |
Valid token without a role of x-genexy-roles | 403 |
| The keys cannot be loaded | 503 |
All of them are problem details. Unknown paths still answer 404.
The caller in your code
| Generator | API |
|---|---|
| Go | auth.FromContext(ctx) returns *auth.Principal, or nil for an anonymous request |
| Rust | platform::auth::principal() returns Option<Arc<Principal>> |
| Spring Boot | CurrentPrincipal.get() returns Optional<Principal> in Java, Principal? in Kotlin |
| ASP.NET Core | RequestContext.Principal returns a Principal, or null for an anonymous request |
| Node.js | ctx.principal of the service method, or currentPrincipal() from src/gen/platform |
| NestJS | currentPrincipal() from src/generated/platform |
A principal has the subject (sub), the username, the roles, the scopes, all claims, and HasRole / HasScope.
Your code never sees a JWT library or a framework type.
Errors on the wire
Every error is an RFC 9457 problem detail (application/problem+json) with type, title, status, detail and
instance (all generators but Spring Boot add requestId), with the same status codes in every generator.
| Situation | Status |
|---|---|
Invalid request (with errors) | 400 |
| Missing or invalid token / missing scope or role | 401 / 403 |
| Unknown path, wrong method, wrong media type | 404 / 405 / 415 |
| Body too large, too many requests | 413 / 429 |
| An HTTP error from your code | its status, with code and message |
| An HTTP error with the contract’s error model | its status, the body exactly as you return it |
| A database constraint violation / a missing row | 409 (with constraint) / 404 |
| A failed call through a generated REST client | 502 |
| A method you have not implemented yet | 501 |
| Request timeout | 503 |
| Any other error | 500, without details (logged with the request id) |
Configuration
The generator writes the values of the spec into a generated configuration file that you do not edit. Put your changes into your own file or into environment variables. They win over the generated file.
| Generator | Generated defaults | Yours | Environment variable of a port number |
|---|---|---|---|
| Go | internal/bootstrap/application.yaml | config/application.yaml, .env | GENEXY_PORTS_API_PORT |
| Rust | src/bootstrap/application.yaml | config/application.yaml, .env | GENEXY_PORTS_API_PORT |
| Spring Boot | app/src/main/resources/application.yml | app/src/main/resources/config/application.yml | GENEXY_PORTS_API_PORT |
| ASP.NET Core | app/genexy.json | app/appsettings.json | GENEXY__PORTS__API__PORT |
| Node.js | src/gen/settings.ts | startServer({ portSettings, security }), env | GENEXY_PORTS_API_PORT |
| NestJS | src/generated/settings.ts | startPorts(app, { portSettings, security }), env | GENEXY_PORTS_API_PORT |
| Settings of | Go, Rust and Spring Boot | ASP.NET Core |
|---|---|---|
| A port and its middleware | genexy.ports.<port>.* | Genexy:Ports:<port>:* |
| The security module of a port | genexy.ports.<port>.security | Genexy:Ports:<port>:Security |
| A security module | genexy.security.<name>.* | Genexy:Security:<name>:* |
| A database | genexy.databases.<name>.* | Genexy:Databases:<name>:* |
Values can read environment variables with ${NAME} or ${NAME:default}, for example
issuer: ${AUTH_ISSUER:http://localhost:8180/realms/shop}. In every generator but Spring Boot, a component with only one port also takes its number from the PORT variable (container platforms). Node.js and NestJS keep the settings in TypeScript: settings.ts holds the values of the spec, the startServer / startPorts options override them per port and per module, and GENEXY_PORTS_<NAME>_PORT sets a port number.
Differences
| Generator | Difference |
|---|---|
| Spring Boot | requestTimeout is not enforced (synchronous Spring MVC); unknownProperties: reject applies to the whole component; the body limit uses Content-Length; no /healthz; no requestId in problem details |
| Node.js | Request bodies are checked only for unknown properties, not against the rest of the schema |
| NestJS | The DTOs do not check string lengths, patterns or number ranges yet |