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.

The real distinction A setting is something the system reads to decide how to behave. A credential is something that can impersonate the extension. They shouldn't be stored the same way, no matter how similar the field definitions look.

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.

Setup table — field trigger
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:

codeunit Secret Value Store
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.

codeunit Secret Value Store
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.

FieldDisplayed asReal value lives in
Client ID722d********IsolatedStorage (Text)
Tenant ID101a********IsolatedStorage (Text)
Client Secret************************IsolatedStorage (SecretText)
Drive Connector Setup card showing masked Client ID, Client Secret, and Tenant ID fields
The setup card in practice — Client ID and Tenant ID masked to the first four characters, Client Secret fully hidden.

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.

page Setup Card
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.

Clear secure storage A "Clear Secure Storage" action that deletes the isolated-storage keys outright and blanks the card has no undo. It's meant for rotating a compromised secret or decommissioning an integration — not a button to click casually.

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 = EndUserIdentifiableInformation still 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

  1. Treat a credential field as a write-only doorway into IsolatedStorage, not a place to keep the value.
  2. Use SecretText for anything that must never be logged, exported, or read back as a plain string.
  3. Mask on every read, not just on save — OnAfterGetRecord is what keeps the card honest.
  4. 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

Popular posts from this blog

Power Apps Portal: How to Display Tooltips on a Form

Run a Dynamics 365 Plugin from JavaScript on Button Click

Multi-Level Approval Workflows in Business Central