How to Securely Store Secrets in Dynamics 365 Business Central: Isolated Storage

March 23, 2026
11 min read
By Maksymilian Meller

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

What is the best way to save secrets in AL?

When developing integrations or extensions for Microsoft Business Central, it is often necessary to store sensitive data such as:

  • API keys
  • OAuth tokens
  • client secrets
  • integration credentials

A common mistake is storing such values directly in tables or configuration fields. Even features like field masking do not actually secure the underlying data.

In my previous post I explained why field masking is not a reliable way to store secrets in Business Central. If you haven’t read it yet, you can find it here: Does Field Masking Protect Secrets?

In this post, I will show the recommended way to store secrets in AL without using external services such as Azure Key Vault.

What Is Isolated Storage?

Isolated Storage is a built-in data storage mechanism that keeps separation between extensions, isolating keys and values between extensions.

Isolated Storage can be accessed through a dedicated IsolatedStorage data type. The storage uses the DataScope option to determine the visibility of stored values.

Technically Isolated Storage is implemented as a separate table with property: DataPerCompany = false This means that the storage exists once per environment rather than per company.

The table cannot to be accessed directly in SaaS environments. Developers should use IsolatedStorage data type.

Isolated Storage Table Structure

  • App ID: Guid PK
  • Scope: Option PK
  • Company Name: Text[30] PK
  • User ID: Guid PK
  • Key: Text[250] PK
  • Value: BLOB
  • Encryption Status: Option
  • Target Value Type: SecretText

The actual value is stored in a BLOB field, which allows secure handling of the data.

Understanding DataScope

DataScope determines who can access the stored secret.

Depending on the scope, different fields are populated when the record is created.

ScopeDescription
ModuleSecret available only within the extension
CompanySecret available for a specific company
UserSecret available for a specific user
CompanyAndUserSecret available only for a specific user in a specific company

When a value is stored, Business Central automatically fills the relevant fields:

Module → only App ID Company → Company Name User → User ID CompanyAndUser → both Company Name and User ID

This design ensures that secrets are properly isolated and scoped.

Supported methods

The IsolatedStorage library provides several methods for working with stored values:

Get

Retrieves the value from Isolated Storage table based on:

  • key
  • DataScope In SaaS environments, the method supports both:
  • Text
  • SecretText

Set

Stores a value in Isolated Storage using specified key and DataScope. If the key does not exist yet, a new record is created.

SetEncrypted

Stores and encryps a value before saving it.

Table input value cannot exceed 215 characters..

Contains

Checks whether a value exists for the given key and DataScope.

Delete

Removes the record associated with the given key and DataScope.

Example Implementation

To demonstrate how Isolated Storage works with different scopes, I created a custom table for managing secrets. The table allows users to define:

  • a secret key
  • a value
  • the desired DataScope

Secrets

Since AL does not allow using the DataScope option directly as a table field, I introduced a separate enum called Data Scope. A mapping function named MapToDataScope converts the enum value to the appropriate DataScope option.

DataScope_Field

The page includes two actions:

Get Secret

Returns a SecretText value for the specified Key and DataScope. DataScope_Field

Set Secret

Stores a secret using a given key and scope. DataScope_Field

If you prefer not to use an action to set the secret, you can add a variable as a page field and call the Get method during field validation.

Code

enum 50400 "Data Scope"
{
Extensible = true;
value(0; "Company")
{
Caption = 'Company';
}
value(1; "CompanyAndUser")
{
Caption = 'Company And User';
}
value(2; "User")
{
Caption = 'User';
}
value(3; "Module")
{
Caption = 'Module';
}
}
table 50400 Secret
{
Caption = 'Secret';
fields
{
field(1; "Code"; Code[20])
{
Caption = 'Code';
}
field(2; Description; Text[250])
{
Caption = 'Description';
}
field(3; "Data Scope"; Enum "Data Scope")
{
Caption = 'Data Scope';
}
field(4; "Secret ID"; Guid)
{
Caption = 'Secret ID';
Editable = false;
}
}
keys
{
key(PK; "Code")
{
Clustered = true;
}
}
trigger OnDelete()
var
IsolatedStorageImpl: Codeunit "Isolated Storage Impl.";
begin
IsolatedStorageImpl.Delete(Rec."Secret ID", Rec.MapToDataScope(Rec."Data Scope"));
end;
procedure MapToDataScope(SecretDataScope: Enum "Data Scope"): DataScope
begin
case SecretDataScope of
SecretDataScope::Company:
exit(DataScope::Company);
SecretDataScope::"CompanyAndUser":
exit(DataScope::CompanyAndUser);
SecretDataScope::Module:
exit(DataScope::Module);
SecretDataScope::User:
exit(DataScope::User);
end;
end;
}

The following codeunit contains all methods needed to manage Isolated Storage:

  • Get Returns SecretText (it can be overloaded to return Text if needed) for given Key and DataScope.
  • Set Creates new secret key if the provided key is empty. The value is stored using the specified or newly generated Key and DataScope. If encryption is enabled in the environment, the method automatically uses SetEncrypted instead of Set.
  • Delete Removes a secret associated with the specified Key and DataScope. Before deleting, the method verifies that the record exists in Isolated Storage.
interface "Isolated Storage"
{
procedure Set(var IsolatedKey: Guid; SecretText: SecretText; Scope: DataScope);
procedure Get(IsolatedKey: Guid; Scope: DataScope): SecretText;
procedure Delete(IsolatedKey: Guid; Scope: DataScope): Boolean;
}
codeunit 50400 "Isolated Storage Impl." implements "Isolated Storage"
{
[NonDebuggable]
procedure Set(var IsolatedKey: Guid; SecretText: SecretText; Scope: DataScope)
begin
if IsNullGuid(IsolatedKey) then
IsolatedKey := CreateGuid();
if EncryptionEnabled() then
IsolatedStorage.SetEncrypted(IsolatedKey, SecretText, Scope)
else
IsolatedStorage.Set(IsolatedKey, SecretText, Scope);
end;
[NonDebuggable]
procedure Get(IsolatedKey: Guid; Scope: DataScope) SecretText: SecretText
begin
if IsolatedStorage.Contains(IsolatedKey, Scope) and not IsNullGuid(IsolatedKey) then
IsolatedStorage.Get(IsolatedKey, Scope, SecretText);
end;
[NonDebuggable]
procedure Delete(IsolatedKey: Guid; Scope: DataScope): Boolean
begin
if not IsolatedStorage.Contains(IsolatedKey, Scope) then
exit;
IsolatedStorage.Delete(IsolatedKey, Scope);
end;
}
page 50400 "Secrets"
{
ApplicationArea = All;
Caption = 'Secrets';
InsertAllowed = false;
PageType = List;
SourceTable = "Secret";
UsageCategory = Administration;
layout
{
area(Content)
{
repeater(General)
{
field(Code; Rec.Code)
{
Editable = false;
ToolTip = 'Specifies the value of the Code field.';
}
field(Description; Rec.Description)
{
ToolTip = 'Specifies the value of the Description field.';
}
field("Data Scope"; Rec."Data Scope")
{
Editable = false;
ToolTip = 'Specifies the value of the Data Scope field.';
}
}
}
}
actions
{
area(Processing)
{
action("Set Secret")
{
ApplicationArea = all;
Caption = 'Set Secret';
Promoted = true;
PromotedCategory = Process;
ToolTip = 'Allows to set a new secret.';
trigger OnAction()
begin
this.SetSecret();
end;
}
action("Get Secret")
{
ApplicationArea = all;
Caption = 'Get Secret';
Promoted = true;
PromotedCategory = Process;
ToolTip = 'Allows to get a secret.';
trigger OnAction()
var
SecretText: SecretText;
begin
SecretText := this.IsolatedStorageImpl.Get(Rec."Secret ID", Rec.MapToDataScope(Rec."Data Scope"));
// Target = OnPrem only
Message(SecretText.Unwrap());
end;
}
}
}
local procedure SetSecret()
var
NewSecret: Record Secret;
SecretDialog: Page "Set Secret Dialog";
SecretText: SecretText;
begin
if SecretDialog.RunModal() = Action::OK then begin
SecretDialog.SetSecret(NewSecret);
SecretText := SecretDialog.GetSecret();
this.IsolatedStorageImpl.Set(NewSecret."Secret ID", SecretText, NewSecret.MapToDataScope(NewSecret."Data Scope"));
NewSecret.Modify(true);
Commit();
end;
end;
var
IsolatedStorageImpl: Codeunit "Isolated Storage Impl.";
}
page 50401 "Set Secret Dialog"
{
ApplicationArea = All;
Caption = 'Set Secret';
PageType = StandardDialog;
layout
{
area(Content)
{
field(Code; this.Code)
{
Caption = 'Code';
ToolTip = 'Specifies the value of the Code field.';
}
field(Description; this.Description)
{
Caption = 'Description';
ToolTip = 'Specifies the value of the Description field.';
}
field("Data Scope"; this.DataScope)
{
Caption = 'Data Scope';
ToolTip = 'Specifies the value of the Data Scope field.';
}
field("Secret Text"; this.SecretText)
{
Caption = 'Secret Text';
MaskType = Concealed;
ToolTip = 'Specifies the value of the Secret Text field.';
}
}
}
procedure SetSecret(var Secret: Record Secret)
begin
Secret.Init();
Secret.Code := this.Code;
Secret.Description := this.Description;
Secret."Data Scope" := this.DataScope;
Secret.Insert(true);
end;
procedure GetSecret(): Text[250]
begin
exit(this.SecretText);
end;
var
Code: Code[20];
Description: Text[250];
DataScope: Enum "Data Scope";
[NonDebuggable]
SecretText: Text[250];
}

Working with Isolated Storage in On-Premises Environments

In On-Prem environments, developers have the option to declare the Isolated Storage table directly as a record variable.

This provides additional access to internal fields such as:

  • Target Value Type
  • Encryption Status

The SecretText data type cannot be used directly in this scenario, because there is no overload of OutStream.WriteText that supports SecretText.

For this reason, using the global IsolatedStorage variable is generally the recommended approach. Direct table access should only be used in very specific scenarios.

codeunit 50403 "Isolated Storage - OnPremOnly"
{
procedure Set(Secret: Record Secret; Value: Text)
var
IsolatedStorageRecord: Record "Isolated Storage";
ModuleInfo: ModuleInfo;
OutStreamValue: OutStream;
begin
NavApp.GetCurrentModuleInfo(ModuleInfo);
IsolatedStorageRecord.Init();
IsolatedStorageRecord."App Id" := ModuleInfo.Id;
IsolatedStorageRecord.Scope := DataScopeToOption(Secret."Data Scope");
IsolatedStorageRecord."Company Name" := CopyStr(CompanyName(), 1, 30);
IsolatedStorageRecord."User Id" := UserSecurityId();
IsolatedStorageRecord."Key" := Secret."Secret ID";
IsolatedStorageRecord.Value.CreateOutStream(OutStreamValue);
OutStreamValue.WriteText(Value);
IsolatedStorageRecord.Insert(true);
end;
procedure Get(SecretID: Guid; Scope: DataScope) ValueText: Text
var
IsolatedStorageRecord: Record "Isolated Storage";
ModuleInfo: ModuleInfo;
ValueInStream: InStream;
begin
NavApp.GetCurrentModuleInfo(ModuleInfo);
if IsolatedStorageRecord.Get(ModuleInfo.Id,
Scope,
CompanyName(),
UserSecurityId(),
SecretID) then begin
IsolatedStorageRecord."Target Value Type" := IsolatedStorageRecord."Target Value Type"::SecretText;
IsolatedStorageRecord.CalcFields(Value);
IsolatedStorageRecord.Value.CreateInStream(ValueInStream);
ValueInStream.ReadText(ValueText);
end;
end;
procedure DataScopeToOption(DataScope: Enum "Data Scope"): Integer
var
IsolatedStorage: Record "Isolated Storage";
begin
case DataScope of
DataScope::Company:
exit(IsolatedStorage.Scope::Company);
DataScope::"CompanyAndUser":
exit(IsolatedStorage.Scope::CompanyAndUser);
DataScope::Module:
exit(IsolatedStorage.Scope::Module);
DataScope::User:
exit(IsolatedStorage.Scope::User);
end;
end;
}
page 50400 "Secrets"
{
ApplicationArea = All;
Caption = 'Secrets';
InsertAllowed = false;
PageType = List;
SourceTable = "Secret";
UsageCategory = Administration;
layout
{
area(Content)
{
repeater(General)
{
field(Code; Rec.Code)
{
Editable = false;
ToolTip = 'Specifies the value of the Code field.';
}
field(Description; Rec.Description)
{
ToolTip = 'Specifies the value of the Description field.';
}
field("Data Scope"; Rec."Data Scope")
{
Editable = false;
ToolTip = 'Specifies the value of the Data Scope field.';
}
}
}
}
actions
{
area(Processing)
{
action("Set Secret OnPrem")
{
ApplicationArea = all;
Caption = 'Set Secret OnPrem';
Promoted = true;
PromotedCategory = Process;
ToolTip = 'Allows to set a new secret.';
trigger OnAction()
begin
this.SetSecretOnPrem();
end;
}
action("Get Secret OnPrem")
{
ApplicationArea = all;
Caption = 'Get Secret OnPrem';
Promoted = true;
PromotedCategory = Process;
ToolTip = 'Allows to get a secret.';
trigger OnAction()
var
SecretText: Text;
begin
Message(SecretText);
end;
}
}
}
local procedure SetSecretOnPrem()
var
NewSecret: Record Secret;
SecretDialog: Page "Set Secret Dialog";
SecretText: Text;
begin
if SecretDialog.RunModal() = Action::OK then begin
SecretDialog.SetSecret(NewSecret);
SecretText := SecretDialog.GetSecret();
this.IsolatedStorageOnPremOnly.Set(NewSecret, SecretText);
NewSecret.Modify(true);
Commit();
end;
end;
var
IsolatedStorageOnPremOnly: Codeunit "Isolated Storage - OnPremOnly";
}

How Secrets Appear in SQL

When stored in the database, the secret value itself is not visible in plain text. Instead, it is stored in a hashed format inside the BLOB field. Below is an example of the Isolated Storage table in SQL. SQL - Isolated Storage table

Best Practices for Storing Secrets in AL

When working with sensitive data in Business Central extensions, it is important to follow a few key principles.

Always Use Isolated Storage

Never store secrets directly in:

  • setup tables
  • configuration tables
  • page fields

Even with masking enabled, the values can still be accessed through the database or APIs.

Prefer SecretText Over Text

Whenever possible, use the SecretText data type instead of Text.

SecretText ensures that sensitive values are handled securely in memory and are not accidentally exposed in logs or telemetry.

Use the Appropriate DataScope

Choose the scope carefully depending on how the secret should be used:

  • Module for extension-level secrets
  • Company for company-specific integrations
  • User for user tokens
  • CompanyAndUser for user-specific company data

Avoid Hardcoding Secrets

Never include secrets directly in AL code or configuration files. Instead, retrieve them securely from Isolated Storage at runtime.

Use Encryption When Available

If the environment supports encryption, prefer using SetEncrypted to store sensitive values.

Conclusion

When working with secrets in Business Central, developers should always consider Isolated Storage as the default mechanism for storing sensitive values.

It provides:

  • isolation between extensions
  • optional encryption
  • built-in API support
  • scope-based access control

For most scenarios where external secret managers like Azure Key Vault are not used, Isolated Storage should be the default choice for storing secrets in AL.