Security and Cryptography
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Visual Basic 17.13 on .NET 10 (SDK 10.0.402) and was checked on 2026-10-19. Every example is a complete program that
starts with Option Strict On, was built with warnings treated as errors and was run in the .NET 10 SDK container (Linux, arm64); the output shown is
real output. All passwords, keys and tokens in the examples are obvious placeholders (placeholder-password, EXAMPLE-NOT-A-REAL-KEY) and none of
them is a real secret. Do not copy a placeholder into a real project, and do not paste a real secret into documentation, an issue or a commit.
Visual Basic has no security features of its own: its security story is the .NET class library, plus a few places where the language’s habits make a
mistake easier (string building, late binding, the leniency of Option Strict Off). The examples therefore use the current .NET APIs directly. The
.NET Framework forms found in older books (SHA256Managed, AesCryptoServiceProvider, RNGCryptoServiceProvider, BinaryFormatter,
System.Data.SqlClient) are listed in a table near the end, with the compiler diagnostics they now produce. This page is a starting point, not an
audit: it does not replace a threat model, a dependency review or the security guidance of your hosting platform.
The checklist
| Risk | What to do in Visual Basic | Section |
|---|---|---|
SQL injection |
Send values as parameters ( |
|
Cross-site scripting |
Encode on output with |
|
Secrets in source |
User secrets for development, environment variables or a vault in production, nothing in the repository or the |
|
Unsafe deserialization |
Typed System.Text.Json, depth limits, DTD processing prohibited in XML, no |
|
Bad input |
Allow-list validation, bounded regular expressions with a timeout, path checks, claim-based authorization. |
|
Weak hashes |
|
|
Predictable randomness |
|
|
Stored passwords |
|
|
Confidentiality and integrity |
|
|
Old APIs |
Fix the |
Obsolete cryptography and the compiler and Analyzers that find these mistakes |
SQL injection
Injection happens when data is concatenated into code. The fix is the same in every database API: the SQL text is a constant, and the values travel
separately as parameters, so the database never parses them as SQL. The program uses Microsoft.Data.Sqlite with an in-memory database, but the same
shape applies to Microsoft.Data.SqlClient (SqlCommand, SqlParameter) and to every ADO.NET provider. The hostile values below are inputs; the
program never builds a statement from them.
Option Strict On
Imports Microsoft.Data.Sqlite
Module Program
Private Function FindByTitle(connection As SqliteConnection, title As String) As List(Of String)
Dim found As New List(Of String)
Using command = connection.CreateCommand()
command.CommandText = "SELECT Title FROM Books WHERE Title = $title"
command.Parameters.Add("$title", SqliteType.Text).Value = title
Using reader = command.ExecuteReader()
Do While reader.Read()
found.Add(reader.GetString(0))
Loop
End Using
End Using
Return found
End Function
Sub Main()
Using connection As New SqliteConnection("Data Source=:memory:")
connection.Open()
Using setup = connection.CreateCommand()
setup.CommandText = "CREATE TABLE Books (Id INTEGER PRIMARY KEY, Title TEXT NOT NULL);" &
"INSERT INTO Books (Title) VALUES ('Dune'), ('Emma'), ('Ulysses');"
setup.ExecuteNonQuery()
End Using
Console.WriteLine($"Dune -> {String.Join(",", FindByTitle(connection, "Dune"))}")
Dim hostile = "x' OR '1'='1"
Console.WriteLine($"{hostile} -> {FindByTitle(connection, hostile).Count} rows (the value is data, not SQL)")
Dim dropper = "'; DROP TABLE Books; --"
Console.WriteLine($"{dropper} -> {FindByTitle(connection, dropper).Count} rows")
Using count = connection.CreateCommand()
count.CommandText = "SELECT COUNT(*) FROM Books"
Console.WriteLine($"Books table still has {count.ExecuteScalar()} rows")
End Using
End Using
End Sub
End Module
The project needs the package reference:
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
</ItemGroup>
Dune -> Dune
x' OR '1'='1 -> 0 rows (the value is data, not SQL)
'; DROP TABLE Books; -- -> 0 rows
Books table still has 3 rows
The quote that would end a SQL string literal is stored and compared as ordinary characters, the second value that would have ended the statement and started a new one found nothing, and the table still holds three rows. Other points:
-
Parameters protect values, not identifiers. A table or column name cannot be a parameter. Choose it from an allow-list in code (
Select Caseover known names), never from the request. -
EF Core. LINQ queries are parameterised automatically, and
FromSqltakes aFormattableString: Visual Basic converts an interpolated string to one, and EF turns each hole into a parameter (next example). The raw-string overloadFromSqlRawloses that protection when you build the text yourself. See Data Access: ADO.NET and EF Core. -
Stored procedures are safe only if the procedure itself does not concatenate strings into dynamic SQL.
-
Least privilege. The account in the connection string should be able to do what the program needs and nothing else;
Microsoft.Data.SqlClientencrypts the connection by default (EncryptisTrue). -
The CA2100 analyzer ("Review SQL queries for security vulnerabilities") flags a command text built from non-constant strings; see Analyzers that find these mistakes.
Source: SQL Injection
Interpolated strings and EF Core
ToQueryString shows what EF Core will send. The interpolated hole became the parameter @p0, and the hostile text is shown as a quoted value, not as part of the statement
(the package is Microsoft.EntityFrameworkCore.Sqlite 10.0.0; the scratch project sets NuGetAudit off because that version pulls a SQLite native package with
a known advisory, as mentioned in the data-access page):
Option Strict On
Imports Microsoft.EntityFrameworkCore
Public Class BookRow
Public Property Id As Integer
Public Property Title As String = ""
End Class
Public Class ShelfContext
Inherits DbContext
Public Property Books As DbSet(Of BookRow)
Protected Overrides Sub OnConfiguring(optionsBuilder As DbContextOptionsBuilder)
optionsBuilder.UseSqlite("Data Source=:memory:")
End Sub
End Class
Module Program
Sub Main()
Using db As New ShelfContext()
Dim title = "x' OR '1'='1"
Dim q = db.Books.FromSql($"SELECT * FROM Books WHERE Title = {title}")
Console.WriteLine(q.ToQueryString())
End Using
End Sub
End Module
.param set p0 'x'' OR ''1''=''1'
SELECT * FROM Books WHERE Title = @p0
Output encoding and XSS
Cross-site scripting happens when untrusted text reaches an HTML page unencoded. Encode at the point of output, using the encoder for the context: HTML body, attribute, JavaScript string or URL. The BCL has all four.
Option Strict On
Imports System.Net
Imports System.Text.Encodings.Web
Imports System.Web
Module Program
Sub Main()
Dim title = "<script>alert('x')</script> & ""quotes"""
Console.WriteLine($"raw : {title}")
Console.WriteLine($"HtmlEncode : {WebUtility.HtmlEncode(title)}")
Console.WriteLine($"HttpUtility : {HttpUtility.HtmlEncode(title)}")
Console.WriteLine($"HtmlEncoder : {HtmlEncoder.Default.Encode(title)}")
Console.WriteLine($"attribute : {HttpUtility.HtmlAttributeEncode(title)}")
Console.WriteLine($"JavaScript : {JavaScriptEncoder.Default.Encode(title)}")
Console.WriteLine($"URL component : {Uri.EscapeDataString("Dune & Emma/1965?")}")
Dim page = $"<li class=""title"">{WebUtility.HtmlEncode(title)}</li>"
Console.WriteLine(page)
End Sub
End Module
raw : <script>alert('x')</script> & "quotes"
HtmlEncode : <script>alert('x')</script> & "quotes"
HttpUtility : <script>alert('x')</script> & "quotes"
HtmlEncoder : <script>alert('x')</script> & "quotes"
attribute : <script>alert('x')</script> & "quotes"
JavaScript : \u003Cscript\u003Ealert(\u0027x\u0027)\u003C/script\u003E \u0026 \u0022quotes\u0022
URL component : Dune%20%26%20Emma%2F1965%3F
<li class="title"><script>alert('x')</script> & "quotes"</li>
-
WebUtility.HtmlEncode(inSystem.Net) andHttpUtility.HtmlEncode(inSystem.Web, part of the shared framework) escape<,>,&,"and'.HtmlEncoder.DefaultfromSystem.Text.Encodings.Webis the encoder ASP.NET Core uses and is the better choice for new code; it prints the single quote as'. -
HtmlAttributeEncodeis for values inside attribute quotes. It encodes quotes and&but leaves<alone, which is correct only inside quotes; always quote attribute values. -
JavaScriptEncoderis for data placed in a script, andUri.EscapeDataStringfor a URL component. Putting untrusted data inside an inline<script>or an event-handler attribute is dangerous even with encoding; pass it as data (adata-attribute or JSON) instead. -
Web Forms (.NET Framework, Windows and IIS only) encodes with
<%: %>; the older<%= %>does not. The following fragment is not built here, because a Web Forms project cannot build on .NET 10:
<%-- Page.aspx: the colon HTML-encodes the value --%>
<li><%: CurrentBook.Title %></li>
<li><%= CurrentBook.Title %></li> <%-- written as is: an XSS risk for untrusted data --%>
-
ASP.NET Core has no Visual Basic templates and no Razor support for Visual Basic. A C# web host with Razor encodes automatically, and a Visual Basic library should return plain data (strings, not HTML) so that the host does the encoding. See Web, HTTP and Services and Security Hardening for anti-forgery tokens,
Content-Security-Policyand the other response headers, and Web Forms security for request validation and view-state protection.
Source: WebUtility.HtmlEncode
Source: HtmlEncoder
Secrets and configuration
A secret in source control is a leaked secret. Keep the key out of the code, the .vbproj, appsettings.json and the repository, and read it from a
configuration source that the environment supplies. The configuration system layers providers; later providers override earlier ones.
For development, the Secret Manager stores values in a per-user file outside the project. These commands ran in the scratch project, which has
<UserSecretsId>shelf-docs-example</UserSecretsId> in its .vbproj:
dotnet user-secrets set "Shelf:ApiKey" "EXAMPLE-NOT-A-REAL-KEY" --project App.vbproj
dotnet user-secrets list --project App.vbproj
Successfully saved Shelf:ApiKey to the secret store.
Shelf:ApiKey = EXAMPLE-NOT-A-REAL-KEY
The program reads defaults from memory, secrets from the user-secrets file and overrides from environment variables, in that order:
Option Strict On
Imports System.Reflection
Imports Microsoft.Extensions.Configuration
Public Class ShelfOptions
Public Property CatalogName As String = "default"
Public Property ApiKey As String = ""
Public Property MaxLoans As Integer = 3
End Class
Module Program
Private Function Mask(value As String) As String
If String.IsNullOrEmpty(value) Then Return "(not set)"
Return New String("*"c, value.Length)
End Function
Private Function Load() As IConfigurationRoot
Return New ConfigurationBuilder() _
.AddInMemoryCollection(New Dictionary(Of String, String) From {
{"Shelf:CatalogName", "Main branch"},
{"Shelf:MaxLoans", "3"}}) _
.AddUserSecrets(Assembly.GetExecutingAssembly()) _
.AddEnvironmentVariables(prefix:="SHELF_") _
.Build()
End Function
Sub Main()
Dim options = Load().GetSection("Shelf").Get(Of ShelfOptions)()
Console.WriteLine($"catalog : {options.CatalogName}")
Console.WriteLine($"api key : {Mask(options.ApiKey)} ({options.ApiKey.Length} characters, from user secrets)")
Console.WriteLine($"loans : {options.MaxLoans}")
' Environment variables are the last provider, so they win. "__" stands for ":".
Environment.SetEnvironmentVariable("SHELF_Shelf__MaxLoans", "5")
Environment.SetEnvironmentVariable("SHELF_Shelf__ApiKey", "EXAMPLE-OVERRIDE-NOT-A-REAL-KEY")
Dim overridden = Load().GetSection("Shelf").Get(Of ShelfOptions)()
Console.WriteLine($"after environment override: loans {overridden.MaxLoans}, api key {Mask(overridden.ApiKey)} ({overridden.ApiKey.Length} characters)")
End Sub
End Module
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
</ItemGroup>
catalog : Main branch
api key : ********************** (22 characters, from user secrets)
loans : 3
after environment override: loans 5, api key ******************************* (31 characters)
-
The key was stored by
dotnet user-secrets, read throughAddUserSecrets, and masked before printing. Never print a secret, not even to a log file. Configuration objects are bound withGet(Of ShelfOptions)(), which works for a Visual Basic class with settable properties. -
The environment variable
SHELF_Shelf__MaxLoansoverrode the value: the prefix is stripped and a double underscore stands for the colon in the key. That is how containers and CI systems inject production values. -
A Visual Basic
Modulecannot be the type argument ofAddUserSecrets(Of T), so the example passes the executing assembly instead. -
User secrets are not encrypted; they only keep the value out of the repository. For production use the platform’s secret store (Azure Key Vault, Kubernetes secrets, a CI vault) and a managed identity where possible. Rotate any secret that was ever committed, even if the commit was removed.
-
Add secret-scanning (for example a pre-commit hook) to the repository. A scanner reports false positives on documentation placeholders like the ones on this page; record those as such rather than disabling the scanner.
-
SecureStringis not a solution: it is not recommended for new code and offers no protection on .NET beyond clearing memory.
Source: Configuration in .NET
See Configuration and Options for the ASP.NET Core view of the same system.
Deserializing untrusted data
Deserialization turns bytes into objects, and some formats let the bytes choose the objects. Prefer formats that bind to a type you name, and bound the input.
Option Strict On
Imports System.IO
Imports System.Text.Json
Imports System.Xml
Public Class BookDto
Public Property Title As String
Public Property Year As Integer
End Class
Module Program
Sub Main()
' JSON: deserialize into a plain data type, never into types chosen by the payload.
Dim good = JsonSerializer.Deserialize(Of BookDto)("{""Title"":""Dune"",""Year"":1965}")
Console.WriteLine($"typed JSON : {good.Title}, {good.Year}")
Try
Dim bad = JsonSerializer.Deserialize(Of BookDto)("{""Title"":""Dune"",""Year"":""soon""}")
Catch ex As JsonException
Console.WriteLine($"bad value : JsonException at {ex.Path}")
End Try
' Limit nesting depth for hostile input.
Dim deep = New String("["c, 100) & New String("]"c, 100)
Dim options As New JsonSerializerOptions With {.MaxDepth = 16}
Try
JsonSerializer.Deserialize(Of Object)(deep, options)
Catch ex As JsonException
Console.WriteLine($"depth limit : JsonException ({options.MaxDepth} levels allowed)")
End Try
' XML: DTD processing is prohibited by default in XmlReader; keep it that way.
Dim hostile = "<?xml version=""1.0""?><!DOCTYPE root [<!ENTITY big ""expansion"">]><root>&big;</root>"
Try
Using reader = XmlReader.Create(New StringReader(hostile))
Do While reader.Read()
Loop
End Using
Catch ex As XmlException
Console.WriteLine($"default DTD : XmlException: {ex.Message}")
End Try
Dim safe As New XmlReaderSettings With {.DtdProcessing = DtdProcessing.Prohibit, .MaxCharactersInDocument = 10000, .XmlResolver = Nothing}
Using reader = XmlReader.Create(New StringReader("<root><title>Dune</title></root>"), safe)
reader.ReadToFollowing("title")
Console.WriteLine($"safe reader : {reader.ReadElementContentAsString()}")
End Using
End Sub
End Module
typed JSON : Dune, 1965
bad value : JsonException at $.Year
depth limit : JsonException (16 levels allowed)
default DTD : XmlException: For security reasons DTD is prohibited in this XML document. To enable DTD processing set the DtdProcessing property on XmlReaderSettings to Parse and pass the settings into XmlReader.Create method.
safe reader : Dune
-
System.Text.Json deserialized into
BookDto, a plain data class, and a wrong type ("Year":"soon") became aJsonExceptionwith the path of the bad value. The payload cannot name a .NET type to instantiate. -
MaxDepthdefaults to 64; lower it for hostile input. A hundred nested arrays failed against a limit of 16. -
XmlReaderprohibits DTD processing by default on .NET (the message says so). The risky settings areDtdProcessing.Parseand anXmlResolver; leave them off, and setMaxCharactersInDocumentfor untrusted input. -
BinaryFormatterwas removed from .NET 9 and must not be used for any input; see Serialization: JSON and XML for the removal and the replacements. -
Never deserialize polymorphic types by name from untrusted JSON, and avoid
TypeNameHandling-style options in third-party libraries.
Source: System.Text.Json overview
Source: BinaryFormatter security guide
Validating input, paths and roles
Option Strict On
Imports System.IO
Imports System.Security.Claims
Imports System.Text.RegularExpressions
Module Program
Private ReadOnly BorrowerPattern As New Regex("^[A-Za-z][A-Za-z '\-]{1,39}$", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100))
Private Function IsValidBorrower(name As String) As Boolean
Return name IsNot Nothing AndAlso BorrowerPattern.IsMatch(name)
End Function
Private Function ResolveInside(root As String, relative As String) As String
Dim fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) & Path.DirectorySeparatorChar
Dim candidate = Path.GetFullPath(Path.Combine(fullRoot, relative))
If Not candidate.StartsWith(fullRoot, StringComparison.Ordinal) Then
Throw New UnauthorizedAccessException($"'{relative}' escapes the shelf folder.")
End If
Return candidate
End Function
Sub Main()
For Each name In {"Ada Lovelace", "O'Brien", "x", "<script>", Nothing}
Console.WriteLine($"borrower [{name}] valid: {IsValidBorrower(name)}")
Next
Dim root = "/srv/shelf/covers"
For Each relative In {"dune.png", "sub/emma.png", "../../etc/passwd", "/etc/passwd"}
Try
Console.WriteLine($"{relative} -> {ResolveInside(root, relative)}")
Catch ex As UnauthorizedAccessException
Console.WriteLine($"{relative} -> rejected: {ex.Message}")
End Try
Next
Dim librarian As New ClaimsPrincipal(New ClaimsIdentity({New Claim(ClaimTypes.Name, "placeholder-user"), New Claim(ClaimTypes.Role, "Librarian")}, "demo"))
Console.WriteLine($"IsInRole(Librarian): {librarian.IsInRole("Librarian")}, IsInRole(Admin): {librarian.IsInRole("Admin")}")
' A pattern with catastrophic backtracking is cut off by the match timeout.
Dim risky As New Regex("^(a+)+$", RegexOptions.None, TimeSpan.FromMilliseconds(200))
Try
Console.WriteLine(risky.IsMatch(New String("a"c, 40) & "!"))
Catch ex As RegexMatchTimeoutException
Console.WriteLine("risky pattern -> RegexMatchTimeoutException")
End Try
End Sub
End Module
borrower [Ada Lovelace] valid: True
borrower [O'Brien] valid: True
borrower [x] valid: False
borrower [<script>] valid: False
borrower [] valid: False
dune.png -> /srv/shelf/covers/dune.png
sub/emma.png -> /srv/shelf/covers/sub/emma.png
../../etc/passwd -> rejected: '../../etc/passwd' escapes the shelf folder.
/etc/passwd -> rejected: '/etc/passwd' escapes the shelf folder.
IsInRole(Librarian): True, IsInRole(Admin): False
risky pattern -> RegexMatchTimeoutException
-
Allow-list, then length. The pattern states what a borrower name may contain and how long it is;
xand<script>fail. ANothinginput is rejected before the pattern runs. -
Regex timeouts. A pattern such as
(a+)+has catastrophic backtracking. The match timeout turned the hang into aRegexMatchTimeoutException. Give every expression that handles untrusted text a timeout, or useRegexOptions.NonBacktracking. -
Path traversal.
Path.Combinewith a rooted second argument discards the first (/etc/passwd), and..segments escape the root. Resolve withPath.GetFullPathand check that the result still starts with the root plus a separator. Without the trailing separator/srv/shelf/covers-oldwould pass a prefix check against/srv/shelf/covers. -
Roles and claims.
ClaimsPrincipal.IsInRoleis the current authorization primitive; code-access-security attributes from .NET Framework do not exist on .NET. ASP.NET Core adds policies on top; see Authorization. -
Validate on the server even when the user interface also validates; a client is not a trust boundary.
Source: ClaimsPrincipal
Hashing and message authentication
A hash is a fixed-size fingerprint. Use SHA-256 or stronger for integrity and identifiers; never use MD5 or SHA-1 for security. SHA256.HashData is a static
method that returns the hash in one call, with no object to create or dispose.
Option Strict On
Imports System.IO
Imports System.Security.Cryptography
Imports System.Text
Module Program
Sub Main()
Dim data = Encoding.UTF8.GetBytes("Dune")
' One-shot hashing: no object to create or dispose.
Dim hash = SHA256.HashData(data)
Console.WriteLine($"SHA-256 : {Convert.ToHexStringLower(hash)}")
Console.WriteLine($"SHA-512 : {Convert.ToHexStringLower(SHA512.HashData(data)).Substring(0, 32)}...")
Console.WriteLine($"SHA3-256 supported on this platform: {SHA3_256.IsSupported}")
' Hashing a stream without loading it into memory.
Using stream As New MemoryStream(data)
Dim streamHash = SHA256.HashData(stream)
Console.WriteLine($"stream hash equals byte-array hash: {CryptographicOperations.FixedTimeEquals(hash, streamHash)}")
End Using
' Keyed hashing: HMAC. The key below is an obvious placeholder, not a secret.
Dim key = Encoding.UTF8.GetBytes("placeholder-key-not-a-secret")
Dim mac = HMACSHA256.HashData(key, data)
Console.WriteLine($"HMAC : {Convert.ToHexStringLower(mac)}")
' Compare MACs in constant time.
Dim received = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes("Dune"))
Dim tampered = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes("Dunes"))
Console.WriteLine($"valid MAC accepted : {CryptographicOperations.FixedTimeEquals(mac, received)}")
Console.WriteLine($"changed data rejected: {Not CryptographicOperations.FixedTimeEquals(mac, tampered)}")
End Sub
End Module
SHA-256 : baa25eb4ed6ca6eda34b8af32f78de3ac89cce7aa40f915606e92275a001cdaf
SHA-512 : ae45c1b1f4d0069a98ca89f128eb3913...
SHA3-256 supported on this platform: True
stream hash equals byte-array hash: True
HMAC : 00c53a3cf55e550e3850a6e0412d7a4370f732976aa406f208aa7bd09129f69f
valid MAC accepted : True
changed data rejected: True
-
SHA256.HashDatahas overloads for a byte array, a span and aStream. The same staticHashDataexists onSHA512,SHA384,SHA3_256and the other algorithms. SHA-3 depends on the platform’s crypto library; checkSHA3_256.IsSupportedfirst. -
A plain hash does not authenticate anything: anyone can compute it. A keyed hash (
HMACSHA256.HashData(key, data)) proves that someone who knows the key made the message. -
Compare hashes and MACs with
CryptographicOperations.FixedTimeEquals.=andSequenceEqualstop at the first difference, which leaks how many leading bytes were right. -
A hash is the wrong tool for passwords: it is fast on purpose. Use Password hashing.
-
Convert.ToHexStringLower(.NET 9 and later) returns lowercase hex;Convert.ToHexStringreturns uppercase.
Source: SHA256.HashData
Source: HMACSHA256.HashData
Random numbers and tokens
Option Strict On
Imports System.Buffers.Text
Imports System.Security.Cryptography
Module Program
Sub Main()
Dim token = RandomNumberGenerator.GetBytes(32)
Console.WriteLine($"random bytes : {token.Length}")
Console.WriteLine($"URL-safe token : {Base64Url.EncodeToString(token).Length} characters")
Console.WriteLine($"hex string : {RandomNumberGenerator.GetHexString(16).Length} characters")
Dim alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
Dim code = RandomNumberGenerator.GetString(alphabet, 8)
Console.WriteLine($"loan code length : {code.Length}, all from alphabet: {code.All(Function(c) alphabet.Contains(c))}")
Dim rolls As New SortedDictionary(Of Integer, Integer)
For i = 1 To 6000
Dim roll = RandomNumberGenerator.GetInt32(1, 7)
rolls(roll) = If(rolls.ContainsKey(roll), rolls(roll), 0) + 1
Next
Console.WriteLine($"GetInt32(1, 7) values seen: {String.Join(",", rolls.Keys)} (upper bound is exclusive)")
' System.Random is predictable: the same seed gives the same sequence.
Dim first As New Random(42)
Dim second As New Random(42)
Dim same = True
For i = 1 To 5
same = same AndAlso first.Next() = second.Next()
Next
Console.WriteLine($"Random(42) twice gives the same sequence: {same}")
Dim filled(3) As Byte
RandomNumberGenerator.Fill(filled)
Console.WriteLine($"Fill wrote {filled.Length} bytes")
End Sub
End Module
random bytes : 32
URL-safe token : 43 characters
hex string : 16 characters
loan code length : 8, all from alphabet: True
GetInt32(1, 7) values seen: 1,2,3,4,5,6 (upper bound is exclusive)
Random(42) twice gives the same sequence: True
Fill wrote 4 bytes
-
RandomNumberGeneratoris the cryptographically secure source.GetBytes(32)returns a new array,Fillfills a buffer,GetInt32(1, 7)returns a uniform integer where the upper bound is exclusive,GetHexStringandGetString(alphabet, length)build identifiers and codes without the modulo bias thatGetBytes(...)(0) Mod 6would introduce. -
System.Randomis a simulation generator. The same seed gives the same sequence, which is exactly what makes it useless for tokens, password-reset links and session identifiers. The CA5394 rule flags it. The parameterlessNew Random()is not suitable for secrets either. -
Base64Url.EncodeToString(inSystem.Buffers.Text, .NET 9 and later) produces a token that is safe in URLs: 32 random bytes are 43 characters, with no padding. -
RNGCryptoServiceProvideris obsolete (SYSLIB0023); the static methods above replace it.
Source: RandomNumberGenerator
Source: Base64Url
Password hashing
Store a password as the output of a deliberately slow, salted key-derivation function. PBKDF2 is in the BCL. The class below stores the algorithm version, the iteration count, the salt and the hash in one string, so the work factor can be raised later without breaking old records.
Option Strict On
Imports System.Security.Cryptography
Imports System.Text
Imports System.Diagnostics
Public NotInheritable Class PasswordHasher
Private Const SaltSize As Integer = 16
Private Const HashSize As Integer = 32
Private Const Iterations As Integer = 600000
Public Shared Function Hash(password As String) As String
Dim salt = RandomNumberGenerator.GetBytes(SaltSize)
Dim derived = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashSize)
Return $"v1.{Iterations}.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(derived)}"
End Function
Public Shared Function Verify(password As String, stored As String) As Boolean
Dim parts = stored.Split("."c)
Dim iterations As Integer
If parts.Length <> 4 OrElse parts(0) <> "v1" OrElse Not Integer.TryParse(parts(1), iterations) Then
Return False
End If
Dim salt = Convert.FromBase64String(parts(2))
Dim expected = Convert.FromBase64String(parts(3))
Dim actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, expected.Length)
Return CryptographicOperations.FixedTimeEquals(actual, expected)
End Function
End Class
Module Program
Sub Main()
' Placeholder password for the demonstration only.
Dim stored = PasswordHasher.Hash("placeholder-password")
Dim parts = stored.Split("."c)
Console.WriteLine($"format : {parts(0)}.{parts(1)}.<salt>.<hash>")
Console.WriteLine($"salt bytes : {Convert.FromBase64String(parts(2)).Length}")
Console.WriteLine($"hash bytes : {Convert.FromBase64String(parts(3)).Length}")
Console.WriteLine($"correct password accepted: {PasswordHasher.Verify("placeholder-password", stored)}")
Console.WriteLine($"wrong password rejected : {Not PasswordHasher.Verify("placeholder-passwor", stored)}")
Console.WriteLine($"same password, new salt : {PasswordHasher.Hash("placeholder-password") <> stored}")
Console.WriteLine($"malformed record rejected: {Not PasswordHasher.Verify("x", "garbage")}")
Dim watch = Stopwatch.StartNew()
PasswordHasher.Hash("placeholder-password")
watch.Stop()
Console.WriteLine($"one hash took more than 1 ms: {watch.Elapsed.TotalMilliseconds > 1}")
End Sub
End Module
format : v1.600000.<salt>.<hash>
salt bytes : 16
hash bytes : 32
correct password accepted: True
wrong password rejected : True
same password, new salt : True
malformed record rejected: True
one hash took more than 1 ms: True
-
Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, length)is the one-shot static method. The constructors are obsolete (SYSLIB0041, and SYSLIB0060 on .NET 10). -
The salt is 16 random bytes from
RandomNumberGenerator, unique per password, and stored next to the hash. It defeats precomputed tables and makes equal passwords hash differently (the "new salt" line). -
The iteration count here is 600,000, the value the OWASP Password Storage Cheat Sheet gives for PBKDF2-HMAC-SHA256 at the time of writing; check the current guidance, tune it on your own hardware, and raise it over time. The final line only asserts that one hash takes measurable time.
-
Verifyre-derives with the stored iteration count and compares in constant time; a malformed record returnsFalserather than throwing. -
Argon2 and bcrypt are not in the BCL. In ASP.NET Core applications, use the Identity
PasswordHasher, which does the versioning for you. -
Never log a password or a hash; never email a password; do not limit password length to a small maximum.
Source: Rfc2898DeriveBytes.Pbkdf2
Authenticated encryption with AesGcm
Encryption without authentication lets an attacker change the ciphertext in ways that decrypt to something else. AES-GCM encrypts and authenticates in one operation. The rules: a 256-bit key (32 bytes), a 12-byte nonce that is never reused with the same key, a 16-byte tag, and optional associated data that is authenticated but not encrypted.
Option Strict On
Imports System.Security.Cryptography
Imports System.Text
Module Program
Sub Main()
Dim key = RandomNumberGenerator.GetBytes(32) ' 256-bit key
Dim plaintext = Encoding.UTF8.GetBytes("Loan 42: Dune, due 2026-11-02")
Dim associated = Encoding.UTF8.GetBytes("loan-record-v1") ' authenticated, not encrypted
Dim nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize) ' 12 bytes
Dim tag(AesGcm.TagByteSizes.MaxSize - 1) As Byte ' 16 bytes
Dim cipher(plaintext.Length - 1) As Byte
Using aes As New AesGcm(key, tag.Length)
aes.Encrypt(nonce, plaintext, cipher, tag, associated)
End Using
Console.WriteLine($"key {key.Length} bytes, nonce {nonce.Length} bytes, tag {tag.Length} bytes, ciphertext {cipher.Length} bytes")
Console.WriteLine($"ciphertext differs from plaintext: {Not cipher.SequenceEqual(plaintext)}")
Dim recovered(cipher.Length - 1) As Byte
Using aes As New AesGcm(key, tag.Length)
aes.Decrypt(nonce, cipher, tag, recovered, associated)
End Using
Console.WriteLine($"decrypted: {Encoding.UTF8.GetString(recovered)}")
cipher(0) = CByte(cipher(0) Xor 1) ' flip one bit
Try
Using aes As New AesGcm(key, tag.Length)
aes.Decrypt(nonce, cipher, tag, recovered, associated)
End Using
Catch ex As AuthenticationTagMismatchException
Console.WriteLine($"tampered data -> {ex.GetType().Name}")
Console.WriteLine($"it is a CryptographicException: {TypeOf ex Is CryptographicException}")
End Try
cipher(0) = CByte(cipher(0) Xor 1) ' restore
Try
Using aes As New AesGcm(key, tag.Length)
aes.Decrypt(nonce, cipher, tag, recovered, Encoding.UTF8.GetBytes("other-context"))
End Using
Catch ex As AuthenticationTagMismatchException
Console.WriteLine("different associated data -> AuthenticationTagMismatchException")
End Try
Try
Dim shortKey = New AesGcm(New Byte(9) {}, 16)
Catch ex As CryptographicException
Console.WriteLine($"10-byte key -> {ex.GetType().Name}: {ex.Message}")
End Try
End Sub
End Module
key 32 bytes, nonce 12 bytes, tag 16 bytes, ciphertext 29 bytes
ciphertext differs from plaintext: True
decrypted: Loan 42: Dune, due 2026-11-02
tampered data -> AuthenticationTagMismatchException
it is a CryptographicException: True
different associated data -> AuthenticationTagMismatchException
10-byte key -> CryptographicException: Specified key is not a valid size for this algorithm.
-
The tag-size constructor
New AesGcm(key, 16)is the current form; the key-only constructor is obsolete (SYSLIB0053).AesGcmimplementsIDisposable, so it sits inUsing. -
Decryption of modified data, a modified tag, a wrong nonce or different associated data throws
AuthenticationTagMismatchException, which derives fromCryptographicException. Treat it as "this message is not authentic" and do not reveal more to the caller. -
A random 12-byte nonce is acceptable for a moderate number of messages per key; beyond that, use a counter or a different key per message. Store the nonce and tag next to the ciphertext (they are not secret).
-
The key must come from
RandomNumberGeneratoror from a key-derivation function, not from a literal and not from a password directly. Where the key lives (a vault, a managed identity) matters more than the algorithm. -
AesGcm.IsSupportedtells whether the platform provides it.Aes.Create()with CBC mode gives confidentiality but no authentication, and CBC with padding is open to padding-oracle attacks when errors are observable; preferAesGcmor a library that wraps authenticated encryption. -
Asymmetric operations follow the same advice:
RSA.Create(2048)or larger with OAEP padding,ECDsafor signatures,RSA.Createrather thanRSACryptoServiceProvider, and certificates from the platform store.
Source: AesGcm
Source: .NET cryptography model
Obsolete cryptography and the compiler
Books written for .NET Framework use classes that .NET now marks obsolete, with a compiler warning that carries a diagnostic id. The warnings below
were produced by compiling one scratch program (not reproduced here) that touched each type once; with TreatWarningsAsErrors they are errors. They
are warnings in a default project, which is why they survive in old code.
| Old API | Diagnostic | Compiler message (shortened) | Use instead |
|---|---|---|---|
|
SYSLIB0021 |
Derived cryptographic types are obsolete. Use the Create method on the base type instead. |
|
|
SYSLIB0022 |
The Rijndael and RijndaelManaged types are obsolete. Use Aes instead. |
|
|
SYSLIB0023 |
RNGCryptoServiceProvider is obsolete. To generate a random number, use one of the RandomNumberGenerator static methods instead. |
|
|
SYSLIB0060 (all constructors, measured on .NET 10); older SDKs reported SYSLIB0041 for constructors with weak defaults |
The constructors on Rfc2898DeriveBytes are obsolete. Use the static Pbkdf2 method instead. |
|
|
SYSLIB0045 |
Cryptographic factory methods accepting an algorithm name are obsolete. Use the parameterless Create factory method on the algorithm type instead. |
|
|
SYSLIB0053 |
AesGcm should indicate the required tag size for encryption and decryption. Use a constructor that accepts the tag size. |
|
|
SYSLIB0011 (removed from .NET 9) |
Not part of the scratch program; see the serialization page for the measured behaviour. |
System.Text.Json, |
PasswordDeriveBytes produced no warning in the same program, so it must be found by review: it is based on the weak PBKDF1 algorithm. In addition,
SHA1.Create() compiled without a warning, and MD5.HashData and SHA1.HashData are flagged only by the CA rules below.
Visual Basic books of the .NET Framework era (for example hashing a password with SHA256Managed or encrypting with AesCryptoServiceProvider in CBC mode and a
key derived from the password) therefore teach code that is at best obsolete and often insecure.
Source: SYSLIB0021
Source: SYSLIB0023
Source: SYSLIB0060
Analyzers that find these mistakes
The .NET analyzers ship with the SDK and are on by default at a low level. Raising AnalysisMode enables the security rules. A scratch project with
<AnalysisMode>All</AnalysisMode> and a program that called MD5.HashData, SHA1.HashData, New Random().Next() and built an SqliteCommand from a
string variable produced:
Program.vb(6,17): warning CA5351: Main uses a broken cryptographic algorithm MD5 (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca5351)
Program.vb(7,17): warning CA5350: Main uses a weak cryptographic algorithm SHA1 (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca5350)
Program.vb(8,17): warning CA5394: Random is an insecure random number generator. Use cryptographically secure random number generators when randomness is required for security. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca5394)
Program.vb(10,20): warning CA2100: Review if the query string passed to 'Sub SqliteCommand.New(commandText As String)' in 'Main', accepts any user input (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2100)
Program.vb(10,20): warning CA2000: Call System.IDisposable.Dispose on object created by 'New Microsoft.Data.Sqlite.SqliteCommand(sql)' before all references to it are out of scope (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2000)
| Rule | Meaning |
|---|---|
CA5350 |
Weak cryptographic algorithm (SHA-1). |
CA5351 |
Broken cryptographic algorithm (MD5, DES). |
CA5394 |
|
CA2100 |
SQL command text built from a non-constant string. |
CA2000 |
An |
Set the severity of each rule in .editorconfig (dotnet_diagnostic.CA5350.severity = error) and treat the security categories as errors in CI; see
XML Documentation, Analyzers and Code Style.
Source: CA5351
Source: CA2100
Transport, CSRF and headers
Cross-site request forgery, security headers and TLS belong to the web host, which for Visual Basic is a C# ASP.NET Core project over a Visual Basic library, or a Web Forms project on .NET Framework. The rules that matter to Visual Basic code are:
-
Use HTTPS and the default certificate validation. A
HttpClienthandler whoseServerCertificateCustomValidationCallbackreturnsTrueturns off the protection TLS gives; fix the certificate instead. -
Anti-forgery tokens protect state-changing browser requests that rely on cookies. Web Forms has
ViewStateUserKeyand event validation; ASP.NET Core has the anti-forgery middleware. -
Response headers (
Content-Security-Policy,Strict-Transport-Security,X-Content-Type-Options) are set in the host’s middleware. -
Logging. Never log secrets, tokens, passwords or full card numbers; log identifiers and outcomes.
-
Dependencies. Run
dotnet list package --vulnerableand keep packages current. The scratch projects on this page setNuGetAuditoff only to keep documentation builds reproducible offline; leave it on in real projects.
Source: Enforce HTTPS in ASP.NET Core
Where the books are outdated
| Older advice | Current practice |
|---|---|
Hash with |
|
Store a password as a plain hash, or as a hash with a fixed salt. |
PBKDF2 (or Argon2 or bcrypt from a library) with a random per-password salt, a stored iteration count and constant-time comparison. |
Build SQL with |
Parameters, with |
|
Removed; use a typed text format. |
Build the secure web front end with Web Forms or |
A C# ASP.NET Core host with a Visual Basic library; Web Forms stays on .NET Framework with security fixes only. |
Role checks with code-access-security attributes. |
|
Version notes
-
SHA256.HashDataand the other staticHashDatamethods arrived in .NET 5;RandomNumberGenerator.GetBytes(Int32)in .NET 6 andGetInt32in .NET Core 3.0;GetHexStringandGetStringin .NET 8. -
Rfc2898DeriveBytes.Pbkdf2arrived in .NET 6.AesGcmis available on Windows, Linux and macOS in modern .NET; checkAesGcm.IsSupportedon unusual platforms. -
Convert.ToHexStringLowerandBase64Urlare .NET 9 additions; on older runtimes useToLowerInvariantonToHexStringand a manual URL-safe Base64. -
Visual Basic 17.13 consumes all of these APIs without special syntax. The one Visual Basic-specific limitation met here is that
Span(Of T)cannot be declared as a field, local or parameter type; the cryptography APIs accept arrays through implicit conversion, so the examples never needed it.
References
-
.NET security, Cryptography model, Cross-platform cryptography and Timing vulnerabilities with CBC-mode symmetric decryption using padding
-
SHA256.HashData, HMACSHA256.HashData, RandomNumberGenerator, Rfc2898DeriveBytes.Pbkdf2, AesGcm and CryptographicOperations.FixedTimeEquals
-
SYSLIB0011, SYSLIB0021, SYSLIB0022, SYSLIB0023, SYSLIB0041, SYSLIB0045, SYSLIB0053 and SYSLIB0060
-
ASP.NET Core security, Prevent Cross-Site Scripting, App secrets, Role-based authorization and SecureString remarks
-
BinaryFormatter security guide, BinaryFormatter migration guide, XmlReaderSettings.DtdProcessing and JsonSerializerOptions.MaxDepth
-
OWASP Password Storage Cheat Sheet (community reference, not official Microsoft documentation), the source of the iteration count discussed above