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.DefaultModel'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.
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 (execution side) — it can never be evaluated in a
browser, and no component that runs there can reach one.
What you may not do with the value#
The read gives you the plaintext, so the compiler governs where that plaintext may go. Four things are compile errors, not warnings — a warning can be ignored and a leaked credential cannot:
| refused | why |
|---|---|
| returning it, from any function, method or constructor | a return value goes to the caller, and a caller can be a page action — the key would land in component state and 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 | 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 |
string SigningKey() { return Secret.CarrierWebhook; } // ✗ returned
new AuditRow { Token = Secret.CarrierWebhook }; // ✗ stored
Log.Information(Secret.CarrierWebhook); // ✗ logged — and shown, on the clientWhat 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 through locals, concatenation, interpolation, ternaries and string helpers, so renaming it
on the way out does not evade it. It stops at a call into your own functions: Sign(Secret.K, msg) hands the key
to code the compiler cannot see, and what that code does with it is yours to get right. So this confines an
accidental leak, not a determined one — and it could not be otherwise, since giving the key to an outbound call is
the sanctioned use.
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);
}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 Osyrin.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
- execution side — why an expression that reads a secret pins its code to the server