The Secret Value Store: keeping credentials out of your tables
A reusable AL pattern for storing API keys, client secrets, and access tokens so they never sit in a readable field — not on the card, not in the database, not over the API.
- Storage
- IsolatedStorage + SecretText
- Table role
- Write-only capture, never at rest
- On screen
- Masked, always
- Via API
- Unreachable — not a field
1. Why a text field is the wrong home for a secret
Almost every setup table in a Business Central extension ends up needing to hold something sensitive — an API key, an OAuth client secret, a webhook signing token. The instinctive way to store it is a Text[250] field, maybe with ExtendedDatatype = Masked so it shows as dots on the card.
That solves exactly one problem: what a user sees when the card is open. It does nothing about what SQL keeps, what a full-table export contains, or what an API page or query built on top of that table hands back to anyone with read access. Masking is a display property. The field underneath still holds plain text, and plain text in a table field is reachable from more places than a developer usually thinks to check.
2. Move the value the moment it's typed
The pattern that holds up is to never let a secret rest in a table field at all. The field exists only to catch the keystroke. Its OnValidate trigger pushes the value straight into IsolatedStorage — a company-scoped, encrypted key/value store with no field, no page surface, and no place in a query — and then overwrites its own value before the record is even saved.
field(3; "Client Secret"; Text[250])
{
Caption = 'Client Secret';
DataClassification = EndUserIdentifiableInformation;
ExtendedDatatype = Masked;
trigger OnValidate()
begin
SecretValueStore.SaveSecretValue(
FieldName("Client Secret"),
"Client Secret");
"Client Secret" := '************************';
end;
}
The field's own value is asterisks the instant it's saved — the plain text never survives the trigger.
Not everything needs the same level of secrecy. A Client ID is meant to be looked up again in Azure AD; it's sensitive at a glance but not a bearer credential the way a secret is. Values like that can round-trip as plain Text instead of SecretText, and come back partially masked rather than fully hidden. The store exposes both paths side by side:
procedure SaveValue(KeyName: Text; Value: Text)
begin
if Value = '' then
exit;
IsolatedStorage.Set(KeyName, Value, DataScope::Company);
end;
procedure SaveSecretValue(KeyName: Text; Value: SecretText)
begin
IsolatedStorage.Set(KeyName, Value, DataScope::Company);
end;
procedure GetValue(KeyName: Text): Text
var
Result: Text;
begin
if IsolatedStorage.Get(KeyName, DataScope::Company, Result) then
exit(Result);
end;
procedure GetSecretValue(KeyName: Text): SecretText
var
Result: SecretText;
begin
IsolatedStorage.Get(KeyName, DataScope::Company, Result);
exit(Result);
end;
Two parallel paths in, two parallel paths out — SecretText for anything that must never be logged or read back as a plain string, plain Text for values that only need to stay off the screen.
3. Show something, not nothing
A blank field is confusing — there's no way to tell "not configured" from "configured, but hidden." So values like a Client ID or Tenant ID are shown partially masked: the first few characters, then asterisks. Enough to recognise which registration is set up, not enough to use.
procedure GetMaskedValue(Value: Text): Text
begin
if Value = '' then
exit('');
if StrLen(Value) <= 4 then
exit('****');
exit(CopyStr(Value, 1, 4) + '********');
end;
722d-rftg... becomes 722d******** on the card — identifiable, not usable.
| Field | Displayed as | Real value lives in |
|---|---|---|
| Client ID | 722d******** | IsolatedStorage (Text) |
| Tenant ID | 101a******** | IsolatedStorage (Text) |
| Client Secret | ************************ | IsolatedStorage (SecretText) |
Run this on every read, not only on save — a mask that's only applied once can be edited around. Re-deriving it in OnAfterGetRecord keeps the card honest no matter how the record got loaded.
4. Wire the page to reload, not to remember
The setup page stays deliberately thin. It shouldn't be deletable or duplicable, and beyond editing the fields it needs exactly two things: a way to refresh the masked values, and a way to wipe isolated storage outright when a secret needs rotating.
trigger OnOpenPage()
begin
if not Rec.Get() then begin
Rec.Init();
Rec.Insert();
end;
Rec.LoadMaskedValues();
end;
trigger OnAfterGetRecord()
begin
Rec.LoadMaskedValues();
end;
Masking is re-applied on open and on every record read — never a one-time step someone could accidentally undo.
5. Why this keeps it out of the API
This is the part that matters most for anyone building an API page against Business Central: IsolatedStorage isn't a table, so it can't be a field, and anything that isn't a field can't appear in a page, a query, or an OData/API entity built on top of one. There's no page you could accidentally publish that leaks the secret — the value simply isn't reachable from anywhere except the AL procedures inside the store that already know the storage key.
- No table field holds the plain secret at rest — only a masked placeholder does.
DataClassification = EndUserIdentifiableInformationstill flags the field correctly for BC's data-classification tooling, even though the live value has already moved on.- The only way back to the real value is a
GetSecretValue()call, made from server-side code that already needs it — never surfaced to a page, an API, or a report.
6. Reusing it across extensions
Because the codeunit only deals in key names and values, it isn't tied to any one integration. A Drive connector, a payment gateway, a webhook signer — each setup table gets its own OnValidate triggers calling the same four procedures, keyed by its own field names. The pattern that keeps a Client Secret off the wire is the same one that keeps an API key or a signing token off it too.
In practice, setting one up looks like configuring any other table: paste the value in, tab off the field, and it masks itself before the record is even saved. The security is invisible until a developer goes looking for it — which is exactly the point.
Takeaways
- Treat a credential field as a write-only doorway into
IsolatedStorage, not a place to keep the value. - Use
SecretTextfor anything that must never be logged, exported, or read back as a plain string. - Mask on every read, not just on save —
OnAfterGetRecordis what keeps the card honest. - If it isn't a table field, it can't leak through a page, a query, or an API entity.
Conclusion
A credential doesn't need a clever field type or a heavier permission model — it needs to never sit somewhere readable in the first place. Route it through IsolatedStorage the moment it's typed, keep the plain value out of every field, mask what the user sees on every read, and the rest follows: nothing to leak through a page, a query, or an API entity, and nothing for an export or a report to accidentally carry along. The same four procedures cover every credential your extension will ever need to hold.
Thank you
Dharmendra Chavda
Comments
Post a Comment