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 (Command.Parameters, FromSql with an interpolated string); never build SQL with & or interpolation.

SQL injection

Cross-site scripting

Encode on output with WebUtility.HtmlEncode, HtmlEncoder, <%: %> in Web Forms, Razor’s automatic encoding in C# hosts.

Output encoding and XSS

Secrets in source

User secrets for development, environment variables or a vault in production, nothing in the repository or the .vbproj.

Secrets and configuration

Unsafe deserialization

Typed System.Text.Json, depth limits, DTD processing prohibited in XML, no BinaryFormatter.

Deserializing untrusted data

Bad input

Allow-list validation, bounded regular expressions with a timeout, path checks, claim-based authorization.

paths and roles

Weak hashes

SHA256.HashData, HMACSHA256.HashData, CryptographicOperations.FixedTimeEquals.

Hashing and message authentication

Predictable randomness

RandomNumberGenerator for anything an attacker must not guess; System.Random only for simulations and tests.

Random numbers and tokens

Stored passwords

Rfc2898DeriveBytes.Pbkdf2 with a random salt, a high iteration count and a stored work factor; or the ASP.NET Core Identity hasher.

Password hashing

Confidentiality and integrity

AesGcm with a fresh 12-byte nonce per message and 256-bit keys.

Authenticated encryption with AesGcm

Old APIs

Fix the SYSLIB warnings; enable the CA rules.

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 Case over known names), never from the request.

  • EF Core. LINQ queries are parameterised automatically, and FromSql takes a FormattableString: Visual Basic converts an interpolated string to one, and EF turns each hole into a parameter (next example). The raw-string overload FromSqlRaw loses 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.SqlClient encrypts the connection by default (Encrypt is True).

  • 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    : &lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt; &amp; &quot;quotes&quot;
HttpUtility   : &lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt; &amp; &quot;quotes&quot;
HtmlEncoder   : &lt;script&gt;alert(&#x27;x&#x27;)&lt;/script&gt; &amp; &quot;quotes&quot;
attribute     : &lt;script>alert(&#39;x&#39;)&lt;/script> &amp; &quot;quotes&quot;
JavaScript    : \u003Cscript\u003Ealert(\u0027x\u0027)\u003C/script\u003E \u0026 \u0022quotes\u0022
URL component : Dune%20%26%20Emma%2F1965%3F
<li class="title">&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt; &amp; &quot;quotes&quot;</li>
  • WebUtility.HtmlEncode (in System.Net) and HttpUtility.HtmlEncode (in System.Web, part of the shared framework) escape <, >, &, " and '. HtmlEncoder.Default from System.Text.Encodings.Web is the encoder ASP.NET Core uses and is the better choice for new code; it prints the single quote as &#x27;.

  • HtmlAttributeEncode is for values inside attribute quotes. It encodes quotes and & but leaves < alone, which is correct only inside quotes; always quote attribute values.

  • JavaScriptEncoder is for data placed in a script, and Uri.EscapeDataString for a URL component. Putting untrusted data inside an inline <script> or an event-handler attribute is dangerous even with encoding; pass it as data (a data- 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-Policy and the other response headers, and Web Forms security for request validation and view-state protection.

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 through AddUserSecrets, and masked before printing. Never print a secret, not even to a log file. Configuration objects are bound with Get(Of ShelfOptions)(), which works for a Visual Basic class with settable properties.

  • The environment variable SHELF_Shelf__MaxLoans overrode 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 Module cannot be the type argument of AddUserSecrets(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.

  • SecureString is not a solution: it is not recommended for new code and offers no protection on .NET beyond clearing memory.

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 a JsonException with the path of the bad value. The payload cannot name a .NET type to instantiate.

  • MaxDepth defaults to 64; lower it for hostile input. A hundred nested arrays failed against a limit of 16.

  • XmlReader prohibits DTD processing by default on .NET (the message says so). The risky settings are DtdProcessing.Parse and an XmlResolver; leave them off, and set MaxCharactersInDocument for untrusted input.

  • BinaryFormatter was 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.

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; x and <script> fail. A Nothing input is rejected before the pattern runs.

  • Regex timeouts. A pattern such as (a+)+ has catastrophic backtracking. The match timeout turned the hang into a RegexMatchTimeoutException. Give every expression that handles untrusted text a timeout, or use RegexOptions.NonBacktracking.

  • Path traversal. Path.Combine with a rooted second argument discards the first (/etc/passwd), and .. segments escape the root. Resolve with Path.GetFullPath and check that the result still starts with the root plus a separator. Without the trailing separator /srv/shelf/covers-old would pass a prefix check against /srv/shelf/covers.

  • Roles and claims. ClaimsPrincipal.IsInRole is 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.HashData has overloads for a byte array, a span and a Stream. The same static HashData exists on SHA512, SHA384, SHA3_256 and the other algorithms. SHA-3 depends on the platform’s crypto library; check SHA3_256.IsSupported first.

  • 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. = and SequenceEqual stop 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.ToHexString returns uppercase.

Source: SHA256.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
  • RandomNumberGenerator is the cryptographically secure source. GetBytes(32) returns a new array, Fill fills a buffer, GetInt32(1, 7) returns a uniform integer where the upper bound is exclusive, GetHexString and GetString(alphabet, length) build identifiers and codes without the modulo bias that GetBytes(...)(0) Mod 6 would introduce.

  • System.Random is 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 parameterless New Random() is not suitable for secrets either.

  • Base64Url.EncodeToString (in System.Buffers.Text, .NET 9 and later) produces a token that is safe in URLs: 32 random bytes are 43 characters, with no padding.

  • RNGCryptoServiceProvider is obsolete (SYSLIB0023); the static methods above replace it.

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.

  • Verify re-derives with the stored iteration count and compares in constant time; a malformed record returns False rather 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.

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). AesGcm implements IDisposable, so it sits in Using.

  • Decryption of modified data, a modified tag, a wrong nonce or different associated data throws AuthenticationTagMismatchException, which derives from CryptographicException. 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 RandomNumberGenerator or 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.IsSupported tells 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; prefer AesGcm or a library that wraps authenticated encryption.

  • Asymmetric operations follow the same advice: RSA.Create(2048) or larger with OAEP padding, ECDsa for signatures, RSA.Create rather than RSACryptoServiceProvider, and certificates from the platform store.

Source: AesGcm

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

SHA256Managed, SHA1CryptoServiceProvider, MD5CryptoServiceProvider, AesCryptoServiceProvider, AesManaged, DESCryptoServiceProvider, TripleDESCryptoServiceProvider

SYSLIB0021

Derived cryptographic types are obsolete. Use the Create method on the base type instead.

SHA256.HashData, Aes.Create(), AesGcm; avoid DES, 3DES and MD5 altogether.

RijndaelManaged

SYSLIB0022

The Rijndael and RijndaelManaged types are obsolete. Use Aes instead.

Aes.Create() or AesGcm.

RNGCryptoServiceProvider

SYSLIB0023

RNGCryptoServiceProvider is obsolete. To generate a random number, use one of the RandomNumberGenerator static methods instead.

RandomNumberGenerator.GetBytes, Fill, GetInt32.

Rfc2898DeriveBytes constructors (and PasswordDeriveBytes)

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.

Rfc2898DeriveBytes.Pbkdf2 with a salt of at least 16 bytes and a high iteration count.

Aes.Create("AES") and other factories taking an algorithm name

SYSLIB0045

Cryptographic factory methods accepting an algorithm name are obsolete. Use the parameterless Create factory method on the algorithm type instead.

Aes.Create(), SHA256.Create().

New AesGcm(key) without a tag size

SYSLIB0053

AesGcm should indicate the required tag size for encryption and decryption. Use a constructor that accepts the tag size.

New AesGcm(key, 16).

BinaryFormatter

SYSLIB0011 (removed from .NET 9)

Not part of the scratch program; see the serialization page for the measured behaviour.

System.Text.Json, XmlSerializer or DataContractSerializer.

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

System.Random used where a secure generator is needed.

CA2100

SQL command text built from a non-constant string.

CA2000

An IDisposable created and not disposed on all paths (this is the missing Using of Best Practices and Common Pitfalls).

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 HttpClient handler whose ServerCertificateCustomValidationCallback returns True turns off the protection TLS gives; fix the certificate instead.

  • Anti-forgery tokens protect state-changing browser requests that rely on cookies. Web Forms has ViewStateUserKey and 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 --vulnerable and keep packages current. The scratch projects on this page set NuGetAudit off only to keep documentation builds reproducible offline; leave it on in real projects.

Where the books are outdated

Older advice Current practice

Hash with SHA256Managed, encrypt with AesCryptoServiceProvider, generate random bytes with RNGCryptoServiceProvider.

SHA256.HashData, AesGcm, RandomNumberGenerator; the old types raise SYSLIB0021 and SYSLIB0023.

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 & and escape quotes by hand, with System.Data.SqlClient.

Parameters, with Microsoft.Data.SqlClient or the provider of your database.

BinaryFormatter for saving objects.

Removed; use a typed text format.

Build the secure web front end with Web Forms or System.Web.

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.

ClaimsPrincipal, ASP.NET Core policies and the Identity system.

Version notes

  • SHA256.HashData and the other static HashData methods arrived in .NET 5; RandomNumberGenerator.GetBytes(Int32) in .NET 6 and GetInt32 in .NET Core 3.0; GetHexString and GetString in .NET 8.

  • Rfc2898DeriveBytes.Pbkdf2 arrived in .NET 6. AesGcm is available on Windows, Linux and macOS in modern .NET; check AesGcm.IsSupported on unusual platforms.

  • Convert.ToHexStringLower and Base64Url are .NET 9 additions; on older runtimes use ToLowerInvariant on ToHexString and 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.