Summary#
Secret.Name reads a declared secret's value inside a function body. It evaluates to the stored string — an API
key, a shared signing key, a token — at the moment the line runs.
The same Secret.Name handle also appears in config slots such as app.Embedding's ApiKey
(declaring secrets (app.Secrets)). There it points at a secret for the platform to resolve; in a function body it is the value,
because that is what code needs in order to sign, compare, or send it.
var expected = Crypto.HmacSha256Hex(Secret.CarrierWebhook, payload);Signature#
Secret.<Name> -> string<Name> is written as an identifier, exactly as declared — Secret.CarrierWebhook for
new Secret("CarrierWebhook"). It is not a string, not a lookup, and takes no arguments.
Description#
A secret has to be declared before it can be read. app.Secrets = [ new Secret("CarrierWebhook") ]; declares it;
osy secret set CarrierWebhook gives it a value on your machine (Giving a secret its value); a deployed
app's values are supplied by whoever operates its platform.
An undeclared name is a compile error. That is deliberate and it is worth knowing why: a mistyped credential that resolved to nothing would make every signature check compare against a key of empty string, which fails exactly like a wrong signature from the sender. You would go looking at the sender, and you would keep looking. The compiler refuses the typo instead.
A declared secret with no value throws when it is read, and says which secret and how to set it. This is also not an empty string, for the same reason — an unset key is the ordinary state of a fresh checkout, and it must not silently degrade into a check that always fails.
Asking whether it is set#
Secret.Name.IsSet answers a bool — whether the secret has a value — without reading it. It is how an app
that works without a key (a payment method offered only once its keys are set, posters fetched only when there is
an API key) decides, instead of wrapping each read in a try:
if (Secret.StripeApiKey.IsSet) { providers.Add(new StripePayments { ApiKey = Secret.StripeApiKey }); }A bool is not a credential, so IsSet is not confined: return it from a helper, show it on a page, test it. The
name must still be declared — IsSet on an undeclared name is the same compile error as reading it.
It is server-side, always#
Reading a secret is a server operation. A credential has no client-side producer, so an expression containing
Secret.Name pins its containing code to the server (Crossing the network) — it can never be evaluated in a
browser, and no component that runs there can reach one.
Where the value may go#
The read gives you the plaintext, so the compiler follows it. A value built from a secret travels freely through your code — locals, concatenation and interpolation, collections you put it in, a helper's parameters, and what a helper returns. It may end only at a sink:
- a request header value — the
Authorizationof anHttp.*call ([[project-secret-sinks#the-rule]]); - the key of a signing call —
Crypto.HmacSha256Hex(key, …),Crypto.SignRs256(key, …); - the mTLS client certificate a package is granted;
- a URL query parameter or request body field that an
egress "host" { secretIn … }line declares for that host (where a secret may be sent — secretIn).
Everything else is a compile error, not a warning — a warning can be ignored and a leaked credential cannot — and the error names the secret, the helpers it came through, and where it ended:
| refused | why |
|---|---|
returning it from a function a request can call — [AllowAnonymous], [Authorize(…)], an [AuthMethod] — or from a component's body | the caller is a visitor or a page, and the key would land on the screen |
| storing it in an entity field | a stored credential is readable by everything that can read that row, rides into backups and exports, and can no longer be rotated by osy secret set |
reading it in anything sent to the browser — an action, a component method, a render slot — or calling a helper that returns it there | evaluating it there means the plaintext was shipped |
handing it to an effect — Log.*, a file write, the clipboard, a dialog, a topic publish, a workflow event payload | each of those puts what it is given somewhere durable or observable. Log is the one that surprises people: it is dual-sided, so a client log line is in the visitor's own browser console as well as in osy logs |
| putting it in an exception's message | a message is logged, traced, and readable by whoever catches it |
putting it in a URL or request body no secretIn line declares — or in any other argument of an outbound call | a URL is written into access logs, proxies and traces; a body is data the far side stores |
[AllowAnonymous] string SigningKey() { return Secret.CarrierWebhook; } // ✗ a visitor could call it
new AuditRow { Token = Secret.CarrierWebhook }; // ✗ stored
Log.Information(Secret.CarrierWebhook); // ✗ logged — and shown, on the client
Http.Get("https://api.example.com/v1?key=" + Secret.CarrierWebhook); // ✗ in the URL, undeclaredA helper that returns what it built from a secret is legal — every provider's Headers() is one. It becomes a
source itself: each call of it is confined exactly as the read would be, so its result may go into a header and
nowhere else. And it is not callable by a request: a page, an API client or an agent that names it is refused, because
its answer would carry the key.
What stays legal is the whole point of the feature: read the secret and use it, then let the RESULT travel. Signing with it, comparing with it, and presenting it to the service it authenticates to are all ordinary code — both examples below do exactly that. A signature or a token you were issued is not a credential of yours, so it may be returned, stored and logged like any other string.
The rule follows the value into your own helpers and back out, so renaming it or passing it through a function does
not evade it. What it does not decide is WHICH host a header goes to — the sanctioned use of a secret is handing it to
an outbound call, and where your code may connect is the egress rules' job (Where your app may connect — the .egress file). osy explain lists
every place each secret leaves the server (Explaining your app's security).
What is persisted is the ciphertext on the secret's own row, and nothing else — a function's expression tree carries only the NAME.
Do secrets work under osy test?#
osy test runs against a throwaway branch of your app built from source, and your project's .secrets values travel
with the run — so a [Test] that exercises a signature check reads the same key the app does, and the credential
path is testable rather than the one part you have to take on faith.
Examples#
Verifying a signed carrier webhook — the key comes from the secret store, and the tag is compared in constant time:
app.Secrets = [ new Secret("CarrierWebhook") ];
bool IsAuthenticScan(string trackingNumber, string location, string signature) {
var expected = Crypto.HmacSha256Hex(Secret.CarrierWebhook, trackingNumber + "|" + location);
return Crypto.FixedTimeEquals(expected, signature);
}Degrading without a key — a lookup that is offered only when its key has been set, and reports why when it is not:
app.Secrets = [ new Secret("PosterApiKey") ];
bool PostersAvailable() => Secret.PosterApiKey.IsSet;
string PosterSource() {
if (!Secret.PosterApiKey.IsSet) { return "title cards — no poster key is set"; }
return Crypto.HmacSha256Hex(Secret.PosterApiKey, "posters");
}Sending one outbound — the same read, used as a bearer token rather than a signing key:
// `use` is a MANIFEST declaration — it belongs in your app.osy, not in a model file.
app Dispatch {
model "model/**/*.osy";
use Osysharp.Http;
}
app.Secrets = [ new Secret("DispatchApi") ];
string FetchManifest(string depot) {
var headers = new Dictionary<string, string>();
headers.Add("Authorization", "Bearer " + Secret.DispatchApi);
var r = Http.Get("https://api.example.com/manifests/" + depot, headers);
return r.IsSuccess ? r.Body : "";
}See also#
- declaring secrets (app.Secrets) —
app.Secrets, where a secret is declared and named - Giving a secret its value —
osy secret set, and which secrets are still empty - Crypto.HmacSha256Hex and Crypto.FixedTimeEquals — verifying a signature with the key you just read, in constant time
- Crossing the network — why an expression that reads a secret pins its code to the server
- where a secret may be sent — secretIn —
egress "host" { secretIn … }: sending a secret in a URL or a body - Explaining your app's security — where each secret leaves the server, as a report