Language
Security
Security in Zornux is a language-level capability, not a web add-on. Trust rules and authorization are enforced by the runtime itself, so every program — not just controllers — gets the same guarantees.
Three built-in defenses
| Concern | Zornux mechanism |
|---|---|
| Authentication | secure my_app using SimpleAuthentication |
| Authorization | restrict to Admin otherwise give back status 403 |
| Data safety | UntrustedText isolation — external input can't be combined with trusted text until it's sanitized or validated. |
Authorization
A restrict to <Role> guard runs before the block
it protects. The runtime checks the current security context; if the role (or
permission) is missing, the otherwise branch decides what
happens — and the protected code never runs.
controller Products at "/products"
require role "admin"
on POST "/" with request_data
add request_data to products
give back created message "Product created!"
end
end
The security context (the current identity, its roles and permissions) is part of the runtime. For controllers, the host supplies it per request; the language itself — never the web layer — enforces the restrict check.
Untrusted input is its own type
Data arriving from outside your program is UntrustedText. You can
show it, but you cannot combine it with trusted
Text using + — the classic injection vector — until
you explicitly clean it. Crossing that line raises
ZX1205 at runtime, turning "remember to sanitize" into a rule the
language enforces.
create comment = untrusted(read_text("comment.txt"))
# show "New comment: " + comment # error ZX1205 — untrusted + trusted
create safe = sanitize(comment) # strips dangerous characters → Text
show "New comment: " + safe # fine — 'safe' is trusted now
The trust-aware built-ins
| Built-in | What it does |
|---|---|
untrusted(text) | Marks a value as UntrustedText. |
sanitize(text) | Removes dangerous characters and returns trusted Text. |
sanitize_lines(text) | Like sanitize, but keeps newlines and tabs — for multi-line untrusted input (file bodies, request bodies) that must keep its structure. |
validate(text) | Returns a Truth — whether the value is already clean. |
trust(text) | Advanced override: trusts a value without cleaning it. Use sparingly. |
File contents come from outside the program, so read_text hands you UntrustedText by design — you decide when it becomes trusted. Note that text(...) converts kinds but preserves taint — it is not a laundering path; go through sanitize / sanitize_lines / trust.
Authentication providers
Attach an authentication provider declaratively with secure … using.
The provider name must be registered, or you get ZX1206. Zornux
ships an in-memory SimpleAuthentication provider; the abstraction
is shaped so real providers (passkeys, API keys, LDAP) slot in
without changing your program.
secure my_app using SimpleAuthentication
For real-world sign-in, Zornux is a first-class OpenID Connect relying party. The auth module runs the Authorization Code flow with mandatory PKCE (auth.discover, auth.begin_login, auth.complete_login, auth.refresh_login, auth.logout_url, auth.verify_jwt) and verifies tokens against the provider's JWKS (RS256/ES256; alg: none refused, exp required). MFA can be delegated to that provider — or handled in-process with native TOTP (see below).
Native authentication toolkit
Beyond delegated sign-in, the auth module carries the pieces of an
authentication system you'd otherwise bolt on — in-process, deterministic, and
constant-time where it matters.
TOTP multi-factor
auth.totp_secret mints a Base32 secret, auth.totp_uri
builds the provisioning URI an authenticator app scans, auth.totp_code
produces the current code, and auth.verify_totp checks a submitted one —
RFC 6238, with a small clock-drift window.
import auth
# Enrollment — a per-user secret and a URI the authenticator app scans.
public function enroll
create secret = auth.totp_secret()
create uri = auth.totp_uri(secret, "ada@example.com", "Zornux")
give back secret
end
# Login step two — verify the 6-digit code.
public function verify_second_factor with secret, submitted_code
give back auth.verify_totp(secret, submitted_code)
end
Sessions, bearer tokens, and refresh
auth.sign_in establishes the principal. In a serve app a
JWT or session cookie is verified (HS256, constant-time) and bridged into the
request's security context via the auth_bearer_secret
setting, so restrict, policies, and claim(...) see the real
caller. Refresh-token rotation keeps the rotated-away token as a tombstone and
revokes the entire session family if a superseded token is ever
replayed — the OWASP reuse defence, on by default.
| Built-in | Purpose |
|---|---|
auth.has_role / has_permission / has_scope / can | Role, permission, OAuth-scope, and ability checks. |
auth.deny(request) | Correct denial status — 401 when anonymous, 403 when authenticated but unauthorized. |
auth.generate_api_key / verify_api_key / fingerprint_api_key | Issue and constant-time-verify API keys. |
auth.lockout / record_failure / record_success / is_locked | Brute-force lockout after repeated failures. |
auth.create_token / read_token / verify_jwt | Mint and verify signed tokens. |
Authorization guards fail closed: an unauthenticated caller, a crashed check, or a non-truth result all deny. Use auth.deny(request) so the status code is correct too — a 401 for "who are you?", a 403 for "not allowed."
Policies & claims
For authorization that's more than a single role, Zornux has first-class
policies: a policy … end block names a set of
require rules (authentication, roles, permissions, claims,
even other policies), and resource-based check blocks decide
with ordinary code. They're enforced with the same guard —
restrict to policy Name otherwise … — and they fail closed.
policy CanManageOrders
require authentication
require role "Manager" or "Owner"
end
restrict to policy CanManageOrders otherwise give back status 403
See the dedicated Authorization page for
claims, custom check blocks with the injected user,
resource-based policies, and automatic denial logging.
Memory safety
Zornux is memory-safe by design. There are no pointers, no manual memory to allocate or free, and no way to reach an address or read past the end of a list — so whole classes of bugs simply can't be written. Values are managed for you, and when a program pushes a real limit it stops with a clear, located message instead of crashing.
| Protection | What it means for you |
|---|---|
| Bounds-checked access | Reading a list past its end is a friendly error, not a bad read; an unknown map key reads as nothing. |
| Depth-bounded recursion | A function that calls itself forever is stopped safely — and you can catch it with try … catch error. |
| Bounded growth | A host can cap how large a list, map, or piece of text may grow, and how deeply data may nest; turning a value that contains itself into JSON stops rather than looping forever. |
| Sandboxed files & packages | File access stays inside an allowed folder (links can't escape it), and packages are checksum-verified and unpacked with protection against path-escape and decompression-bomb archives. |
function descend with n
give back descend(n + 1) # runaway recursion
end
try
show descend(1)
catch error
show "stopped safely" # the program keeps control
end
When a program reaches a memory-safety limit — depth, size, or a malformed package — it stops with a located diagnostic you can read and recover from, exactly the same way every time.
Checking for weaknesses
Beyond the guarantees above, zornux check --security reads your program
the way a reviewer would and reports what it finds: a secret written into the source,
untrusted data reaching a query or a command, a resource opened and never closed, a
route that changes data with nothing guarding it, a cookie giving up a safe default.
Each finding explains itself and suggests a fix. It's opt-in, so an ordinary check is
unchanged.
Auditing your dependencies
Point it at an advisory feed and it will also tell you when a package you depend on has a known vulnerability. The feed is a file you supply — the compiler never reaches the network to fetch one.
zornux check app.zx --security --advisories advisories.json
Advisories are matched against the versions your project actually resolved, including packages you depend on only indirectly. Your project is found by looking upward from the file you checked, so the same versions are read wherever your program lives.
The audit fails closed, because an audit that quietly skips something
is worse than no audit at all. A dependency whose version isn't resolved yet is
reported as not audited — never as safe; run zornux restore
first, and it will be checked like the rest. An advisory written in a way the check
cannot read is named in the output and stops the check, rather than
passing the package it was meant to catch. An advisory whose severity is unrecognized
is treated as an error, never quietly downgraded.
Everything the check could not establish is reported in its output, not only to the person reading the screen — so an automated build, which sees nothing but a pass or a fail, sees the same truth you do.
1. External input is UntrustedText until sanitized or validated.
2. Authorization guards run before the blocks they protect.
For the formal rules behind these guarantees, see the Specification and the Diagnostics catalog (ZX1200–1299).