diff --git a/src/NATS.Server/Auth/Jwt/PermissionTemplates.cs b/src/NATS.Server/Auth/Jwt/PermissionTemplates.cs
new file mode 100644
index 0000000..59e63f0
--- /dev/null
+++ b/src/NATS.Server/Auth/Jwt/PermissionTemplates.cs
@@ -0,0 +1,123 @@
+using System.Text.RegularExpressions;
+
+namespace NATS.Server.Auth.Jwt;
+
+///
+/// Expands mustache-style template strings in NATS JWT permission subjects.
+/// When a user connects with a JWT, template strings in their permissions are
+/// expanded using claim values from the user and account JWTs.
+///
+///
+/// Reference: Go auth.go:424-520 — processUserPermissionsTemplate()
+///
+/// Supported template functions:
+/// {{name()}} — user's Name claim
+/// {{subject()}} — user's Subject (NKey public key)
+/// {{tag(tagname)}} — user tags matching "tagname:" prefix (multi-value → cartesian product)
+/// {{account-name()}} — account display name
+/// {{account-subject()}} — account NKey public key
+/// {{account-tag(tagname)}} — account tags matching "tagname:" prefix (multi-value → cartesian product)
+///
+/// When a template resolves to multiple values (e.g. a user with two "dept:" tags),
+/// the cartesian product of all expanded subjects is returned. If any template
+/// resolves to zero values, the entire pattern is dropped (returns empty list).
+///
+public static partial class PermissionTemplates
+{
+ [GeneratedRegex(@"\{\{([^}]+)\}\}")]
+ private static partial Regex TemplateRegex();
+
+ ///
+ /// Expands a single permission pattern containing zero or more template expressions.
+ /// Returns the list of concrete subjects after substitution.
+ /// Returns an empty list if any template resolves to no values (tag not found).
+ /// Returns a single-element list containing the original pattern if no templates are present.
+ ///
+ public static List Expand(
+ string pattern,
+ string name, string subject,
+ string accountName, string accountSubject,
+ string[] userTags, string[] accountTags)
+ {
+ var matches = TemplateRegex().Matches(pattern);
+ if (matches.Count == 0)
+ return [pattern];
+
+ var replacements = new List<(string Placeholder, string[] Values)>();
+ foreach (Match match in matches)
+ {
+ var expr = match.Groups[1].Value.Trim();
+ var values = ResolveTemplate(expr, name, subject, accountName, accountSubject, userTags, accountTags);
+ if (values.Length == 0)
+ return [];
+ replacements.Add((match.Value, values));
+ }
+
+ // Compute cartesian product across all multi-value replacements.
+ // Start with the full pattern and iteratively replace each placeholder.
+ var results = new List { pattern };
+ foreach (var (placeholder, values) in replacements)
+ {
+ var next = new List();
+ foreach (var current in results)
+ foreach (var value in values)
+ next.Add(current.Replace(placeholder, value));
+ results = next;
+ }
+ return results;
+ }
+
+ ///
+ /// Expands all patterns in a permission list, flattening multi-value expansions
+ /// into the result. Patterns that resolve to no values are omitted entirely.
+ ///
+ public static List ExpandAll(
+ IEnumerable patterns,
+ string name, string subject,
+ string accountName, string accountSubject,
+ string[] userTags, string[] accountTags)
+ {
+ var result = new List();
+ foreach (var pattern in patterns)
+ result.AddRange(Expand(pattern, name, subject, accountName, accountSubject, userTags, accountTags));
+ return result;
+ }
+
+ private static string[] ResolveTemplate(
+ string expr,
+ string name, string subject,
+ string accountName, string accountSubject,
+ string[] userTags, string[] accountTags)
+ {
+ return expr.ToLowerInvariant() switch
+ {
+ "name()" => [name],
+ "subject()" => [subject],
+ "account-name()" => [accountName],
+ "account-subject()" => [accountSubject],
+ _ when expr.StartsWith("tag(", StringComparison.OrdinalIgnoreCase) => ResolveTags(expr, userTags),
+ _ when expr.StartsWith("account-tag(", StringComparison.OrdinalIgnoreCase) => ResolveTags(expr, accountTags),
+ _ => []
+ };
+ }
+
+ ///
+ /// Extracts the tag name from a tag() or account-tag() expression and returns
+ /// all matching tag values from the provided tags array.
+ /// Tags are stored in "key:value" format; this method returns the value portion.
+ ///
+ private static string[] ResolveTags(string expr, string[] tags)
+ {
+ var openParen = expr.IndexOf('(');
+ var closeParen = expr.IndexOf(')');
+ if (openParen < 0 || closeParen < 0)
+ return [];
+
+ var tagName = expr[(openParen + 1)..closeParen].Trim();
+ var prefix = tagName + ":";
+ return tags
+ .Where(t => t.StartsWith(prefix, StringComparison.Ordinal))
+ .Select(t => t[prefix.Length..])
+ .ToArray();
+ }
+}
diff --git a/tests/NATS.Server.Tests/PermissionTemplateTests.cs b/tests/NATS.Server.Tests/PermissionTemplateTests.cs
new file mode 100644
index 0000000..9e9de7c
--- /dev/null
+++ b/tests/NATS.Server.Tests/PermissionTemplateTests.cs
@@ -0,0 +1,99 @@
+namespace NATS.Server.Tests;
+
+using NATS.Server.Auth.Jwt;
+
+public class PermissionTemplateTests
+{
+ [Fact]
+ public void Expand_name_template()
+ {
+ var result = PermissionTemplates.Expand("user.{{name()}}.>",
+ name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
+ userTags: [], accountTags: []);
+ result.ShouldBe(["user.alice.>"]);
+ }
+
+ [Fact]
+ public void Expand_subject_template()
+ {
+ var result = PermissionTemplates.Expand("inbox.{{subject()}}.>",
+ name: "alice", subject: "UABC123", accountName: "acct", accountSubject: "AABC",
+ userTags: [], accountTags: []);
+ result.ShouldBe(["inbox.UABC123.>"]);
+ }
+
+ [Fact]
+ public void Expand_account_name_template()
+ {
+ var result = PermissionTemplates.Expand("acct.{{account-name()}}.>",
+ name: "alice", subject: "UABC", accountName: "myaccount", accountSubject: "AABC",
+ userTags: [], accountTags: []);
+ result.ShouldBe(["acct.myaccount.>"]);
+ }
+
+ [Fact]
+ public void Expand_account_subject_template()
+ {
+ var result = PermissionTemplates.Expand("acct.{{account-subject()}}.>",
+ name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC456",
+ userTags: [], accountTags: []);
+ result.ShouldBe(["acct.AABC456.>"]);
+ }
+
+ [Fact]
+ public void Expand_tag_template_single_value()
+ {
+ var result = PermissionTemplates.Expand("dept.{{tag(dept)}}.>",
+ name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
+ userTags: ["dept:engineering"], accountTags: []);
+ result.ShouldBe(["dept.engineering.>"]);
+ }
+
+ [Fact]
+ public void Expand_tag_template_multi_value_cartesian()
+ {
+ var result = PermissionTemplates.Expand("dept.{{tag(dept)}}.>",
+ name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
+ userTags: ["dept:eng", "dept:sales"], accountTags: []);
+ result.Count.ShouldBe(2);
+ result.ShouldContain("dept.eng.>");
+ result.ShouldContain("dept.sales.>");
+ }
+
+ [Fact]
+ public void Expand_account_tag_template()
+ {
+ var result = PermissionTemplates.Expand("region.{{account-tag(region)}}.>",
+ name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
+ userTags: [], accountTags: ["region:us-east"]);
+ result.ShouldBe(["region.us-east.>"]);
+ }
+
+ [Fact]
+ public void Expand_no_templates_returns_original()
+ {
+ var result = PermissionTemplates.Expand("foo.bar.>",
+ name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
+ userTags: [], accountTags: []);
+ result.ShouldBe(["foo.bar.>"]);
+ }
+
+ [Fact]
+ public void Expand_unknown_tag_returns_empty()
+ {
+ var result = PermissionTemplates.Expand("dept.{{tag(missing)}}.>",
+ name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
+ userTags: ["dept:eng"], accountTags: []);
+ result.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void ExpandAll_expands_array_of_subjects()
+ {
+ var subjects = new[] { "user.{{name()}}.>", "inbox.{{subject()}}.>" };
+ var result = PermissionTemplates.ExpandAll(subjects,
+ name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
+ userTags: [], accountTags: []);
+ result.ShouldBe(["user.alice.>", "inbox.UABC.>"]);
+ }
+}