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.
| Scope | Description |
|---|---|
| Module | Secret available only within the extension |
| Company | Secret available for a specific company |
| User | Secret available for a specific user |
| CompanyAndUser | Secret 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

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.

The page includes two actions:
Get Secret
Returns a SecretText value for the specified Key and DataScope.

Set Secret
Stores a secret using a given key and scope.

If you prefer not to use an action to set the secret, you can add a variable as a page field and call the
Getmethod 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
SetEncryptedinstead ofSet. - 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
SecretTextdata type cannot be used directly in this scenario, because there is no overload ofOutStream.WriteTextthat supportsSecretText.
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.

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.