From 4c67c1a7dbbd051a07285fd2dc15f5e65b462b4f Mon Sep 17 00:00:00 2001
From: Scott Fauerbach <scottfauerbach@gmail.com>
Date: Thu, 1 Feb 2024 11:43:40 -0500
Subject: [PATCH] Revert "Extract json, nkey and jwt utils to libraries
 (#1061)" (#1071)

This reverts commit 7b1b22366a5429afe8034bbffc71a992fa966ecd.
---
 build.gradle                                  |   5 +-
 .../jetstream/NatsJsMirrorSubUseCases.java    |   2 +-
 src/main/java/io/nats/client/NKey.java        | 741 ++++++++++++++
 .../io/nats/client/PullRequestOptions.java    |  16 +-
 .../java/io/nats/client/PurgeOptions.java     |  10 +-
 .../java/io/nats/client/api/ApiResponse.java  |  10 +-
 .../client/api/ConsumerConfiguration.java     |  62 +-
 .../client/api/ConsumerCreateRequest.java     |   5 +-
 .../io/nats/client/api/ConsumerLimits.java    |   8 +-
 .../java/io/nats/client/api/External.java     |   2 +-
 .../nats/client/api/MessageDeleteRequest.java |   2 +-
 .../io/nats/client/api/MessageGetRequest.java |   2 +-
 .../java/io/nats/client/api/MessageInfo.java  |  30 +-
 .../java/io/nats/client/api/ObjectInfo.java   |  15 +-
 .../java/io/nats/client/api/ObjectLink.java   |   8 +-
 .../java/io/nats/client/api/ObjectMeta.java   |  15 +-
 .../io/nats/client/api/ObjectMetaOptions.java |   8 +-
 .../java/io/nats/client/api/Placement.java    |   2 +-
 .../java/io/nats/client/api/Republish.java    |   2 +-
 .../java/io/nats/client/api/SourceBase.java   |  18 +-
 .../nats/client/api/StreamConfiguration.java  |  14 +-
 .../io/nats/client/api/StreamInfoOptions.java |   2 +-
 .../io/nats/client/api/SubjectTransform.java  |   2 +-
 .../io/nats/client/impl/StreamInfoReader.java |   2 +-
 .../io/nats/client/support/DateTimeUtils.java |  80 ++
 .../java/io/nats/client/support/Encoding.java | 265 +++++
 .../io/nats/client/support/HeadersUtils.java  |  37 -
 .../client/support/JsonParseException.java    |  17 +
 .../io/nats/client/support/JsonParser.java    | 439 ++++++++
 .../nats/client/support/JsonSerializable.java |  28 +
 .../io/nats/client/support/JsonUtils.java     | 315 ++++--
 .../io/nats/client/support/JsonValue.java     | 275 +++++
 .../nats/client/support/JsonValueUtils.java   | 375 +++++++
 .../java/io/nats/client/support/JwtUtils.java | 171 ++--
 .../io/nats/client/support/Validator.java     |  30 -
 src/main/java/io/nats/service/Endpoint.java   |  15 +-
 .../java/io/nats/service/EndpointStats.java   |  26 +-
 .../java/io/nats/service/InfoResponse.java    |  11 +-
 src/main/java/io/nats/service/Service.java    |  13 +-
 .../java/io/nats/service/ServiceResponse.java |  18 +-
 .../java/io/nats/service/StatsResponse.java   |   7 +-
 src/test/java/io/nats/client/AuthTests.java   |   7 +-
 src/test/java/io/nats/client/NKeyTests.java   | 600 +++++++++++
 .../client/api/AccountStatisticsTests.java    |   3 +-
 .../nats/client/impl/JetStreamPullTests.java  |  12 +-
 .../nats/client/impl/ListRequestsTests.java   |   3 +-
 .../client/support/DateTimeUtilsTests.java    |  86 ++
 .../io/nats/client/support/EncodingTests.java |   8 -
 .../nats/client/support/JsonParsingTests.java | 950 ++++++++++++++++++
 .../io/nats/client/support/JwtUtilsTests.java |   3 -
 .../java/io/nats/service/ServiceTests.java    |  15 +-
 51 files changed, 4379 insertions(+), 413 deletions(-)
 create mode 100644 src/main/java/io/nats/client/NKey.java
 create mode 100644 src/main/java/io/nats/client/support/DateTimeUtils.java
 create mode 100644 src/main/java/io/nats/client/support/Encoding.java
 delete mode 100644 src/main/java/io/nats/client/support/HeadersUtils.java
 create mode 100644 src/main/java/io/nats/client/support/JsonParseException.java
 create mode 100644 src/main/java/io/nats/client/support/JsonParser.java
 create mode 100644 src/main/java/io/nats/client/support/JsonSerializable.java
 create mode 100644 src/main/java/io/nats/client/support/JsonValue.java
 create mode 100644 src/main/java/io/nats/client/support/JsonValueUtils.java
 create mode 100644 src/test/java/io/nats/client/NKeyTests.java
 create mode 100644 src/test/java/io/nats/client/support/DateTimeUtilsTests.java
 create mode 100644 src/test/java/io/nats/client/support/JsonParsingTests.java

diff --git a/build.gradle b/build.gradle
index e8a9a335e..ec2383178 100644
--- a/build.gradle
+++ b/build.gradle
@@ -36,10 +36,7 @@ repositories {
 }
 
 dependencies {
-    implementation 'io.nats:nkeys-java:1.5.2'
-    implementation 'io.nats:jnats-json:1.5.2'
-    implementation 'io.nats:jwt-java:1.5.2'
-
+    implementation 'net.i2p.crypto:eddsa:0.3.0'
     testImplementation 'org.junit.jupiter:junit-jupiter:5.9.0'
     testImplementation 'io.nats:jnats-server-runner:1.2.8'
     testImplementation 'nl.jqno.equalsverifier:equalsverifier:3.12.3'
diff --git a/src/examples/java/io/nats/examples/jetstream/NatsJsMirrorSubUseCases.java b/src/examples/java/io/nats/examples/jetstream/NatsJsMirrorSubUseCases.java
index 8d14cb8ac..d80648985 100644
--- a/src/examples/java/io/nats/examples/jetstream/NatsJsMirrorSubUseCases.java
+++ b/src/examples/java/io/nats/examples/jetstream/NatsJsMirrorSubUseCases.java
@@ -24,7 +24,7 @@
 import java.time.Duration;
 import java.util.List;
 
-import static io.nats.client.support.JsonWriteUtils.printFormatted;
+import static io.nats.client.support.JsonUtils.printFormatted;
 import static io.nats.examples.jetstream.NatsJsUtils.publish;
 
 /**
diff --git a/src/main/java/io/nats/client/NKey.java b/src/main/java/io/nats/client/NKey.java
new file mode 100644
index 000000000..b2c93c254
--- /dev/null
+++ b/src/main/java/io/nats/client/NKey.java
@@ -0,0 +1,741 @@
+// Copyright 2018 The NATS Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at:
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package io.nats.client;
+
+import net.i2p.crypto.eddsa.EdDSAEngine;
+import net.i2p.crypto.eddsa.EdDSAPrivateKey;
+import net.i2p.crypto.eddsa.EdDSAPublicKey;
+import net.i2p.crypto.eddsa.spec.EdDSANamedCurveSpec;
+import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable;
+import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec;
+import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.security.*;
+import java.util.Arrays;
+
+import static io.nats.client.support.Encoding.base32Decode;
+import static io.nats.client.support.Encoding.base32Encode;
+import static io.nats.client.support.RandomUtils.PRAND;
+import static io.nats.client.support.RandomUtils.SRAND;
+
+class DecodedSeed {
+    int prefix;
+    byte[] bytes;
+}
+
+/**
+ * <p>
+ * The NATS ecosystem will be moving to Ed25519 keys for identity,
+ * authentication and authorization for entities such as Accounts, Users,
+ * Servers and Clusters.
+ * </p>
+ * <p>
+ * NKeys are based on the Ed25519 standard. This signing algorithm provides for
+ * the use of public and private keys to sign and verify data. NKeys is designed
+ * to formulate keys in a much friendlier fashion referencing work done in
+ * cryptocurrencies, specifically Stellar. Bitcoin and others use a form of
+ * Base58 (or Base58Check) to encode raw keys. Stellar utilizes a more
+ * traditional Base32 with a CRC16 and a version or prefix byte. NKeys utilizes
+ * a similar format with one or two prefix bytes. The base32 encoding of these
+ * prefixes will yield friendly human readable prefixes, e.g. 'N' = server, 'C'
+ * = cluster, 'O' = operator, 'A' = account, and 'U' = user to help developers
+ * and administrators quickly identify key types.
+ * </p>
+ * <p>
+ * Each NKey is generated from 32 bytes. These bytes are called the seed and are
+ * encoded, in the NKey world, into a string starting with the letter 'S', with
+ * a second character indicating the key’s type, e.g. "SU" is a seed for a u
+ * er key pair, "SA" is a seed for an account key pair. The seed can be used t
+ *  create the Ed25519 public/private key pair and should be protected as a p
+ * ivate key. It is equivalent to the private key for a PGP key pair, or the m
+ * ster password for your password vault.
+ * </p>
+ * <p>
+ * Ed25519 uses the seed bytes to generate a key pair. The pair contains a
+ * private key, which can be used to sign data, and a public key which can be
+ * used to verify a signature. The public key can be distributed, and is not
+ * considered secret.
+ * </p>
+ * <p>
+ * The NKey libraries encode 32 byte public keys using Base32 and a CRC16
+ * checksum plus a prefix based on the key type, e.g. U for a user key.
+ * </p>
+ * <p>
+ * The NKey libraries have support for exporting a 64 byte private key. This
+ * data is encoded into a string starting with the prefix ‘P’ for private. The
+ * 64 bytes in a private key consists of the 32 bytes of the seed followed by 
+ * he 32 bytes of the public key. Essentially, the private key is redundant sin
+ * e you can get it back from the seed alone. The NATS team recommends sto
+ * ing the 32 byte seed and letting the NKey library regenerate anything els
+ *  it needs for signing.
+ * </p>
+ * <p>
+ * The existence of both a seed and a private key can result in confusion. It is
+ * reasonable to simply think of Ed25519 as having a public key and a private
+ * seed, and ignore the longer private key concept. In fact, the NKey libraries
+ * generally expect you to create an NKey from either a public key, to use for
+ * verification, or a seed, to use for signing.
+ * </p>
+ * <p>
+ * The NATS system will utilize public NKeys for identification, the NATS system
+ * will never store or even have access to any private keys or seeds.
+ * Authentication will utilize a challenge-response mechanism based on a
+ * collection of random bytes called a nonce.
+ * </p>
+ * <p>
+ * Version note - 2.2.0 provided string arguments for seeds, this is not as safe
+ * as char arrays, so in 2.3.0 we have included a breaking change to char arrays.
+ * While this is not the proper version choice, NKeys aren't widely used, if at all yet,
+ * so we are making the change on a minor jump.
+ * </p>
+ */
+public class NKey {
+
+    /**
+     * NKeys use a prefix byte to indicate their intended owner: 'N' = server, 'C' =
+     * cluster, 'A' = account, and 'U' = user. 'P' is used for private keys. The
+     * NKey class formalizes these into the enum NKey.Type.
+     */
+    public enum Type {
+        /** A user NKey. */
+        USER(PREFIX_BYTE_USER),
+        /** An account NKey. */
+        ACCOUNT(PREFIX_BYTE_ACCOUNT),
+        /** A server NKey. */
+        SERVER(PREFIX_BYTE_SERVER),
+        /** An operator NKey. */
+        OPERATOR(PREFIX_BYTE_OPERATOR),
+        /** A cluster NKey. */
+        CLUSTER(PREFIX_BYTE_CLUSTER),
+        /** A private NKey. */
+        PRIVATE(PREFIX_BYTE_PRIVATE);
+
+        private final int prefix;
+
+        Type(int prefix) {
+            this.prefix = prefix;
+        }
+
+        public static Type fromPrefix(int prefix) {
+            if (prefix == PREFIX_BYTE_ACCOUNT) {
+                return ACCOUNT;
+            } else if (prefix == PREFIX_BYTE_SERVER) {
+                return SERVER;
+            } else if (prefix == PREFIX_BYTE_USER) {
+                return USER;
+            } else if (prefix == PREFIX_BYTE_CLUSTER) {
+                return CLUSTER;
+            } else if (prefix == PREFIX_BYTE_PRIVATE) {
+                return ACCOUNT;
+            } else if (prefix == PREFIX_BYTE_OPERATOR) {
+                return OPERATOR;
+            }
+
+            throw new IllegalArgumentException("Unknown prefix");
+        }
+    }
+
+    // PrefixByteSeed is the prefix byte used for encoded NATS Seeds
+    private static final int PREFIX_BYTE_SEED = 18 << 3; // Base32-encodes to 'S...'
+
+    // PrefixBytePrivate is the prefix byte used for encoded NATS Private keys
+    static final int PREFIX_BYTE_PRIVATE = 15 << 3; // Base32-encodes to 'P...'
+
+    // PrefixByteServer is the prefix byte used for encoded NATS Servers
+    static final int PREFIX_BYTE_SERVER = 13 << 3; // Base32-encodes to 'N...'
+
+    // PrefixByteCluster is the prefix byte used for encoded NATS Clusters
+    static final int PREFIX_BYTE_CLUSTER = 2 << 3; // Base32-encodes to 'C...'
+
+    // PrefixByteAccount is the prefix byte used for encoded NATS Accounts
+    static final int PREFIX_BYTE_ACCOUNT = 0; // Base32-encodes to 'A...'
+
+    // PrefixByteUser is the prefix byte used for encoded NATS Users
+    static final int PREFIX_BYTE_USER = 20 << 3; // Base32-encodes to 'U...'
+
+    // PrefixByteOperator is the prefix byte used for encoded NATS Operators
+    static final int PREFIX_BYTE_OPERATOR = 14 << 3; // Base32-encodes to 'O...'
+
+    private static final int ED25519_PUBLIC_KEYSIZE = 32;
+    private static final int ED25519_PRIVATE_KEYSIZE = 64;
+    private static final int ED25519_SEED_SIZE = 32;
+    private static final EdDSANamedCurveSpec ed25519 = EdDSANamedCurveTable.getByName(EdDSANamedCurveTable.ED_25519);
+
+    // XModem CRC based on the go version of NKeys
+    private final static int[] crc16table = { 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7, 0x8108,
+            0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef, 0x1231, 0x0210, 0x3273, 0x2252, 0x52b5, 0x4294,
+            0x72f7, 0x62d6, 0x9339, 0x8318, 0xb37b, 0xa35a, 0xd3bd, 0xc39c, 0xf3ff, 0xe3de, 0x2462, 0x3443, 0x0420,
+            0x1401, 0x64e6, 0x74c7, 0x44a4, 0x5485, 0xa56a, 0xb54b, 0x8528, 0x9509, 0xe5ee, 0xf5cf, 0xc5ac, 0xd58d,
+            0x3653, 0x2672, 0x1611, 0x0630, 0x76d7, 0x66f6, 0x5695, 0x46b4, 0xb75b, 0xa77a, 0x9719, 0x8738, 0xf7df,
+            0xe7fe, 0xd79d, 0xc7bc, 0x48c4, 0x58e5, 0x6886, 0x78a7, 0x0840, 0x1861, 0x2802, 0x3823, 0xc9cc, 0xd9ed,
+            0xe98e, 0xf9af, 0x8948, 0x9969, 0xa90a, 0xb92b, 0x5af5, 0x4ad4, 0x7ab7, 0x6a96, 0x1a71, 0x0a50, 0x3a33,
+            0x2a12, 0xdbfd, 0xcbdc, 0xfbbf, 0xeb9e, 0x9b79, 0x8b58, 0xbb3b, 0xab1a, 0x6ca6, 0x7c87, 0x4ce4, 0x5cc5,
+            0x2c22, 0x3c03, 0x0c60, 0x1c41, 0xedae, 0xfd8f, 0xcdec, 0xddcd, 0xad2a, 0xbd0b, 0x8d68, 0x9d49, 0x7e97,
+            0x6eb6, 0x5ed5, 0x4ef4, 0x3e13, 0x2e32, 0x1e51, 0x0e70, 0xff9f, 0xefbe, 0xdfdd, 0xcffc, 0xbf1b, 0xaf3a,
+            0x9f59, 0x8f78, 0x9188, 0x81a9, 0xb1ca, 0xa1eb, 0xd10c, 0xc12d, 0xf14e, 0xe16f, 0x1080, 0x00a1, 0x30c2,
+            0x20e3, 0x5004, 0x4025, 0x7046, 0x6067, 0x83b9, 0x9398, 0xa3fb, 0xb3da, 0xc33d, 0xd31c, 0xe37f, 0xf35e,
+            0x02b1, 0x1290, 0x22f3, 0x32d2, 0x4235, 0x5214, 0x6277, 0x7256, 0xb5ea, 0xa5cb, 0x95a8, 0x8589, 0xf56e,
+            0xe54f, 0xd52c, 0xc50d, 0x34e2, 0x24c3, 0x14a0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405, 0xa7db, 0xb7fa,
+            0x8799, 0x97b8, 0xe75f, 0xf77e, 0xc71d, 0xd73c, 0x26d3, 0x36f2, 0x0691, 0x16b0, 0x6657, 0x7676, 0x4615,
+            0x5634, 0xd94c, 0xc96d, 0xf90e, 0xe92f, 0x99c8, 0x89e9, 0xb98a, 0xa9ab, 0x5844, 0x4865, 0x7806, 0x6827,
+            0x18c0, 0x08e1, 0x3882, 0x28a3, 0xcb7d, 0xdb5c, 0xeb3f, 0xfb1e, 0x8bf9, 0x9bd8, 0xabbb, 0xbb9a, 0x4a75,
+            0x5a54, 0x6a37, 0x7a16, 0x0af1, 0x1ad0, 0x2ab3, 0x3a92, 0xfd2e, 0xed0f, 0xdd6c, 0xcd4d, 0xbdaa, 0xad8b,
+            0x9de8, 0x8dc9, 0x7c26, 0x6c07, 0x5c64, 0x4c45, 0x3ca2, 0x2c83, 0x1ce0, 0x0cc1, 0xef1f, 0xff3e, 0xcf5d,
+            0xdf7c, 0xaf9b, 0xbfba, 0x8fd9, 0x9ff8, 0x6e17, 0x7e36, 0x4e55, 0x5e74, 0x2e93, 0x3eb2, 0x0ed1, 0x1ef0 };
+
+    static int crc16(byte[] bytes) {
+        int crc = 0;
+
+        for (byte b : bytes) {
+            crc = ((crc << 8) & 0xffff) ^ crc16table[((crc >> 8) ^ (b & 0xFF)) & 0x00FF];
+        }
+
+        return crc;
+    }
+
+
+    private static boolean checkValidPublicPrefixByte(int prefix) {
+        switch (prefix) {
+            case PREFIX_BYTE_SERVER:
+            case PREFIX_BYTE_CLUSTER:
+            case PREFIX_BYTE_OPERATOR:
+            case PREFIX_BYTE_ACCOUNT:
+            case PREFIX_BYTE_USER:
+                return true;
+        }
+        return false;
+    }
+
+    static char[] removePaddingAndClear(char[] withPad) {
+        int i;
+
+        for (i=withPad.length-1;i>=0;i--) {
+            if (withPad[i] != '=') {
+                break;
+            }
+        }
+        char[] withoutPad = new char[i+1];
+        System.arraycopy(withPad, 0, withoutPad, 0, withoutPad.length);
+
+        for (int j=0;j<withPad.length;j++) {
+            withPad[j] = '\0';
+        }
+
+        return withoutPad;
+    }
+
+    static char[] encode(Type type, byte[] src) throws IOException {
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+
+        bytes.write(type.prefix);
+        bytes.write(src);
+
+        int crc = crc16(bytes.toByteArray());
+        byte[] littleEndian = ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort((short) crc).array();
+
+        bytes.write(littleEndian);
+
+        char[] withPad = base32Encode(bytes.toByteArray());
+        return removePaddingAndClear(withPad);
+    }
+
+    static char[] encodeSeed(Type type, byte[] src) throws IOException {
+        if (src.length != ED25519_PRIVATE_KEYSIZE && src.length != ED25519_SEED_SIZE) {
+            throw new IllegalArgumentException("Source is not the correct size for an ED25519 seed");
+        }
+
+        // In order to make this human printable for both bytes, we need to do a little
+        // bit manipulation to setup for base32 encoding which takes 5 bits at a time.
+        int b1 = PREFIX_BYTE_SEED | (type.prefix >> 5);
+        int b2 = (type.prefix & 31) << 3; // 31 = 00011111
+
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+
+        bytes.write(b1);
+        bytes.write(b2);
+        bytes.write(src);
+
+        int crc = crc16(bytes.toByteArray());
+        byte[] littleEndian = ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort((short) crc).array();
+
+        bytes.write(littleEndian);
+
+        char[] withPad = base32Encode(bytes.toByteArray());
+        return removePaddingAndClear(withPad);
+    }
+
+    static byte[] decode(char[] src) {
+        byte[] raw = base32Decode(src);
+
+        if (raw == null || raw.length < 4) {
+            throw new IllegalArgumentException("Invalid encoding for source string");
+        }
+
+        byte[] crcBytes = Arrays.copyOfRange(raw, raw.length - 2, raw.length);
+        byte[] dataBytes = Arrays.copyOfRange(raw, 0, raw.length - 2);
+
+        int crc = ByteBuffer.wrap(crcBytes).order(ByteOrder.LITTLE_ENDIAN).getShort() & 0xFFFF;
+        int actual = crc16(dataBytes);
+
+        if (actual != crc) {
+            throw new IllegalArgumentException("CRC is invalid");
+        }
+
+        return dataBytes;
+    }
+
+    static byte[] decode(Type expectedType, char[] src, boolean safe) {
+        byte[] raw = decode(src);
+        byte[] dataBytes = Arrays.copyOfRange(raw, 1, raw.length);
+        Type type = NKey.Type.fromPrefix(raw[0] & 0xFF);
+
+        if (type != expectedType) {
+            if (safe) {
+                return null;
+            }
+            throw new IllegalArgumentException("Unexpected type");
+        }
+
+        return dataBytes;
+    }
+
+    static DecodedSeed decodeSeed(char[] seed) {
+        byte[] raw = decode(seed);
+
+        // Need to do the reverse here to get back to internal representation.
+        int b1 = raw[0] & 248; // 248 = 11111000
+        int b2 = (raw[0] & 7) << 5 | ((raw[1] & 248) >> 3); // 7 = 00000111
+
+        if (b1 != PREFIX_BYTE_SEED) {
+            throw new IllegalArgumentException("Invalid encoding");
+        }
+
+        if (!checkValidPublicPrefixByte(b2)) {
+            throw new IllegalArgumentException("Invalid encoded prefix byte");
+        }
+
+        byte[] dataBytes = Arrays.copyOfRange(raw, 2, raw.length);
+        DecodedSeed retVal = new DecodedSeed();
+        retVal.prefix = b2;
+        retVal.bytes = dataBytes;
+        return retVal;
+    }
+
+    private static NKey createPair(Type type, SecureRandom random)
+            throws IOException, NoSuchProviderException, NoSuchAlgorithmException {
+        if (random == null) {
+            random = SRAND;
+        }
+
+        byte[] seed = new byte[NKey.ed25519.getCurve().getField().getb() / 8];
+        random.nextBytes(seed);
+
+        return createPair(type, seed);
+    }
+
+    private static NKey createPair(Type type, byte[] seed)
+            throws IOException, NoSuchProviderException, NoSuchAlgorithmException {
+        EdDSAPrivateKeySpec privKeySpec = new EdDSAPrivateKeySpec(seed, NKey.ed25519);
+        EdDSAPrivateKey privKey = new EdDSAPrivateKey(privKeySpec);
+        EdDSAPublicKeySpec pubKeySpec = new EdDSAPublicKeySpec(privKey.getA(), NKey.ed25519);
+        EdDSAPublicKey pubKey = new EdDSAPublicKey(pubKeySpec);
+        byte[] pubBytes = pubKey.getAbyte();
+
+        byte[] bytes = new byte[pubBytes.length + seed.length];
+        System.arraycopy(seed, 0, bytes, 0, seed.length);
+        System.arraycopy(pubBytes, 0, bytes, seed.length, pubBytes.length);
+
+        char[] encoded = encodeSeed(type, bytes);
+        return new NKey(type, null, encoded);
+    }
+
+    /**
+     * Create an Account NKey from the provided random number generator.
+     * 
+     * If no random is provided, SecureRandom() will be used to create one.
+     * 
+     * The new NKey contains the private seed, which should be saved in a secure location.
+     * 
+     * @param random A secure random provider
+     * @return the new Nkey
+     * @throws IOException if the seed cannot be encoded to a string
+     * @throws NoSuchProviderException if the default secure random cannot be created
+     * @throws NoSuchAlgorithmException if the default secure random cannot be created
+     */
+    public static NKey createAccount(SecureRandom random)
+            throws IOException, NoSuchProviderException, NoSuchAlgorithmException {
+        return createPair(Type.ACCOUNT, random);
+    }
+
+    /**
+     * Create an Cluster NKey from the provided random number generator.
+     * 
+     * If no random is provided, SecureRandom() will be used to create one.
+     * 
+     * The new NKey contains the private seed, which should be saved in a secure location.
+     * 
+     * @param random A secure random provider
+     * @return the new Nkey
+     * @throws IOException if the seed cannot be encoded to a string
+     * @throws NoSuchProviderException if the default secure random cannot be created
+     * @throws NoSuchAlgorithmException if the default secure random cannot be created
+     */
+    public static NKey createCluster(SecureRandom random)
+            throws IOException, NoSuchProviderException, NoSuchAlgorithmException {
+        return createPair(Type.CLUSTER, random);
+    }
+
+    /**
+     * Create an Operator NKey from the provided random number generator.
+     * 
+     * If no random is provided, SecureRandom() will be used to create one.
+     * 
+     * The new NKey contains the private seed, which should be saved in a secure location.
+     * 
+     * @param random A secure random provider
+     * @return the new Nkey
+     * @throws IOException if the seed cannot be encoded to a string
+     * @throws NoSuchProviderException if the default secure random cannot be created
+     * @throws NoSuchAlgorithmException if the default secure random cannot be created
+     */
+    public static NKey createOperator(SecureRandom random)
+            throws IOException, NoSuchProviderException, NoSuchAlgorithmException {
+        return createPair(Type.OPERATOR, random);
+    }
+
+    /**
+     * Create a Server NKey from the provided random number generator.
+     * 
+     * If no random is provided, SecureRandom() will be used to create one.
+     * 
+     * The new NKey contains the private seed, which should be saved in a secure location.
+     * 
+     * @param random A secure random provider
+     * @return the new Nkey
+     * @throws IOException if the seed cannot be encoded to a string
+     * @throws NoSuchProviderException if the default secure random cannot be created
+     * @throws NoSuchAlgorithmException if the default secure random cannot be created
+     */
+    public static NKey createServer(SecureRandom random)
+            throws IOException, NoSuchProviderException, NoSuchAlgorithmException {
+        return createPair(Type.SERVER, random);
+    }
+
+    /**
+     * Create a User NKey from the provided random number generator.
+     * 
+     * If no random is provided, SecureRandom() will be used to create one.
+     * 
+     * The new NKey contains the private seed, which should be saved in a secure location.
+     * 
+     * @param random A secure random provider
+     * @return the new Nkey
+     * @throws IOException if the seed cannot be encoded to a string
+     * @throws NoSuchProviderException if the default secure random cannot be created
+     * @throws NoSuchAlgorithmException if the default secure random cannot be created
+     */
+    public static NKey createUser(SecureRandom random)
+            throws IOException, NoSuchProviderException, NoSuchAlgorithmException {
+        return createPair(Type.USER, random);
+    }
+
+    /**
+     * Create an NKey object from the encoded public key. This NKey can be used for verification but not for signing.
+     * 
+     * @param publicKey the string encoded public key
+     * @return the new Nkey
+     */
+    public static NKey fromPublicKey(char[] publicKey) {
+        byte[] raw = decode(publicKey);
+        int prefix = raw[0] & 0xFF;
+
+        if (!checkValidPublicPrefixByte(prefix)) {
+            throw new IllegalArgumentException("Not a valid public NKey");
+        }
+
+        Type type = NKey.Type.fromPrefix(prefix);
+        return new NKey(type, publicKey, null);
+    }
+
+    /**
+     * Creates an NKey object from a string encoded seed. This NKey can be used to sign or verify.
+     * 
+     * @param seed the string encoded seed, see {@link NKey#getSeed() getSeed()}
+     * @return the Nkey
+     */
+    public static NKey fromSeed(char[] seed) {
+        DecodedSeed decoded = decodeSeed(seed); // Should throw on bad seed
+
+        if (decoded.bytes.length == ED25519_PRIVATE_KEYSIZE) {
+            return new NKey(Type.fromPrefix(decoded.prefix), null, seed);
+        } else {
+            try {
+                return createPair(Type.fromPrefix(decoded.prefix), decoded.bytes);
+            } catch (Exception e) {
+                throw new IllegalArgumentException("Bad seed value", e);
+            }
+        }
+    }
+
+    /**
+     * @param src the encoded public key
+     * @return true if the public key is an account public key
+     */
+    public static boolean isValidPublicAccountKey(char[] src) {
+        return decode(Type.ACCOUNT, src, true) != null;
+    }
+
+    /**
+     * @param src the encoded public key
+     * @return true if the public key is a cluster public key
+     */
+    public static boolean isValidPublicClusterKey(char[] src) {
+        return decode(Type.CLUSTER, src, true) != null;
+    }
+
+    /**
+     * @param src the encoded public key
+     * @return true if the public key is an operator public key
+     */
+    public static boolean isValidPublicOperatorKey(char[] src) {
+        return decode(Type.OPERATOR, src, true) != null;
+    }
+
+    /**
+     * @param src the encoded public key
+     * @return true if the public key is a server public key
+     */
+    public static boolean isValidPublicServerKey(char[] src) {
+        return decode(Type.SERVER, src, true) != null;
+    }
+
+    /**
+     * @param src the encoded public key
+     * @return true if the public key is a user public key
+     */
+    public static boolean isValidPublicUserKey(char[] src) {
+        return decode(Type.USER, src, true) != null;
+    }
+
+    /**
+     * The seed or private key per the Ed25519 spec, encoded with encodeSeed.
+     */
+    private char[] privateKeyAsSeed;
+
+    /**
+     * The public key, maybe null. Used for public only NKeys.
+     */
+    private char[] publicKey;
+
+    private Type type;
+
+    private NKey(Type t, char[] publicKey, char[] privateKey) {
+        this.type = t;
+        this.privateKeyAsSeed = privateKey;
+        this.publicKey = publicKey;
+    }
+
+    /**
+     * Clear the seed and public key char arrays by filling them
+     * with random bytes then zero-ing them out.
+     * 
+     * The nkey is unusable after this operation.
+     */
+    public void clear() {
+        if (privateKeyAsSeed != null) {
+            for (int i=0; i< privateKeyAsSeed.length ; i++) {
+                privateKeyAsSeed[i] = (char)(PRAND.nextInt(26) + 'a');
+            }
+            Arrays.fill(privateKeyAsSeed, '\0');
+        }
+        if (publicKey != null) {
+            for (int i=0; i< publicKey.length ; i++) {
+                publicKey[i] = (char)(PRAND.nextInt(26) + 'a');
+            }
+            Arrays.fill(publicKey, '\0');
+        }
+    }
+
+    /**
+     * @return the string encoded seed for this NKey
+     */
+    public char[] getSeed() {
+        if (privateKeyAsSeed == null) {
+            throw new IllegalStateException("Public-only NKey");
+        }
+        DecodedSeed decoded = decodeSeed(privateKeyAsSeed);
+        byte[] seedBytes = new byte[ED25519_SEED_SIZE];
+        System.arraycopy(decoded.bytes, 0, seedBytes, 0, seedBytes.length);
+        try {
+            return encodeSeed(Type.fromPrefix(decoded.prefix), seedBytes);
+        } catch (Exception e) {
+            throw new IllegalStateException("Unable to create seed.", e);
+        }
+    }
+
+    /**
+     * @return the encoded public key for this NKey
+     * 
+     * @throws GeneralSecurityException if there is an encryption problem
+     * @throws IOException              if there is a problem encoding the public
+     *                                  key
+     */
+    public char[] getPublicKey() throws GeneralSecurityException, IOException {
+        if (publicKey != null) {
+            return publicKey;
+        }
+
+        KeyPair keys = getKeyPair();
+        EdDSAPublicKey pubKey = (EdDSAPublicKey) keys.getPublic();
+        byte[] pubBytes = pubKey.getAbyte();
+
+        return encode(this.type, pubBytes);
+    }
+
+    /**
+     * @return the encoded private key for this NKey
+     * 
+     * @throws GeneralSecurityException if there is an encryption problem
+     * @throws IOException              if there is a problem encoding the key
+     */
+    public char[] getPrivateKey() throws GeneralSecurityException, IOException {
+        if (privateKeyAsSeed == null) {
+            throw new IllegalStateException("Public-only NKey");
+        }
+
+        DecodedSeed decoded = decodeSeed(privateKeyAsSeed);
+        return encode(Type.PRIVATE, decoded.bytes);
+    }
+
+    /**
+     * @return A Java security keypair that represents this NKey in Java security
+     *         form.
+     * 
+     * @throws GeneralSecurityException if there is an encryption problem
+     * @throws IOException              if there is a problem encoding or decoding
+     */
+    public KeyPair getKeyPair() throws GeneralSecurityException, IOException {
+        if (privateKeyAsSeed == null) {
+            throw new IllegalStateException("Public-only NKey");
+        }
+
+        DecodedSeed decoded = decodeSeed(privateKeyAsSeed);
+        byte[] seedBytes = new byte[ED25519_SEED_SIZE];
+        byte[] pubBytes = new byte[ED25519_PUBLIC_KEYSIZE];
+
+        System.arraycopy(decoded.bytes, 0, seedBytes, 0, seedBytes.length);
+        System.arraycopy(decoded.bytes, seedBytes.length, pubBytes, 0, pubBytes.length);
+
+        EdDSAPrivateKeySpec privKeySpec = new EdDSAPrivateKeySpec(seedBytes, NKey.ed25519);
+        EdDSAPrivateKey privKey = new EdDSAPrivateKey(privKeySpec);
+        EdDSAPublicKeySpec pubKeySpec = new EdDSAPublicKeySpec(pubBytes, NKey.ed25519);
+        EdDSAPublicKey pubKey = new EdDSAPublicKey(pubKeySpec);
+
+        return new KeyPair(pubKey, privKey);
+    }
+
+    /**
+     * @return the Type of this NKey
+     */
+    public Type getType() {
+        return type;
+    }
+
+    /**
+     * Sign aribitrary binary input.
+     * 
+     * @param input the bytes to sign
+     * @return the signature for the input from the NKey
+     * 
+     * @throws GeneralSecurityException if there is an encryption problem
+     * @throws IOException              if there is a problem reading the data
+     */
+    public byte[] sign(byte[] input) throws GeneralSecurityException, IOException {
+        Signature sgr = new EdDSAEngine(MessageDigest.getInstance(NKey.ed25519.getHashAlgorithm()));
+        PrivateKey sKey = getKeyPair().getPrivate();
+
+        sgr.initSign(sKey);
+        sgr.update(input);
+
+        return sgr.sign();
+    }
+
+    /**
+     * Verify a signature.
+     * 
+     * @param input     the bytes that were signed
+     * @param signature the bytes for the signature
+     * @return true if the signature matches this keys signature for the input.
+     * 
+     * @throws GeneralSecurityException if there is an encryption problem
+     * @throws IOException              if there is a problem reading the data
+     */
+    public boolean verify(byte[] input, byte[] signature) throws GeneralSecurityException, IOException {
+        Signature sgr = new EdDSAEngine(MessageDigest.getInstance(NKey.ed25519.getHashAlgorithm()));
+        PublicKey sKey = null;
+
+        if (privateKeyAsSeed != null) {
+            sKey = getKeyPair().getPublic();
+        } else {
+            char[] encodedPublicKey = getPublicKey();
+            byte[] decodedPublicKey = decode(this.type, encodedPublicKey, false);
+            EdDSAPublicKeySpec pubKeySpec = new EdDSAPublicKeySpec(decodedPublicKey, NKey.ed25519);
+            sKey = new EdDSAPublicKey(pubKeySpec);
+        }
+
+        sgr.initVerify(sKey);
+        sgr.update(input);
+
+        return sgr.verify(signature);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (o == this)
+            return true;
+        if (!(o instanceof NKey)) {
+            return false;
+        }
+
+        NKey otherNKey = (NKey) o;
+
+        if (this.type != otherNKey.type) {
+            return false;
+        }
+
+        if (this.privateKeyAsSeed == null) {
+            return Arrays.equals(this.publicKey, otherNKey.publicKey);
+        }
+
+        return Arrays.equals(this.privateKeyAsSeed, otherNKey.privateKeyAsSeed);
+    }
+
+    @Override
+    public int hashCode() {
+        int result = 17;
+        result = 31 * result + this.type.prefix;
+
+        if (this.privateKeyAsSeed == null) {
+            result = 31 * result + Arrays.hashCode(this.publicKey);
+        } else {
+            result = 31 * result + Arrays.hashCode(this.privateKeyAsSeed);
+        }
+        return result;
+    }
+
+}
\ No newline at end of file
diff --git a/src/main/java/io/nats/client/PullRequestOptions.java b/src/main/java/io/nats/client/PullRequestOptions.java
index 2c97e7328..e1feb91f0 100644
--- a/src/main/java/io/nats/client/PullRequestOptions.java
+++ b/src/main/java/io/nats/client/PullRequestOptions.java
@@ -14,11 +14,11 @@
 package io.nats.client;
 
 import io.nats.client.support.JsonSerializable;
+import io.nats.client.support.JsonUtils;
 
 import java.time.Duration;
 
 import static io.nats.client.support.ApiConstants.*;
-import static io.nats.client.support.JsonWriteUtils.*;
 import static io.nats.client.support.Validator.validateGtZero;
 
 /**
@@ -42,13 +42,13 @@ public PullRequestOptions(Builder b) {
 
     @Override
     public String toJson() {
-        StringBuilder sb = beginJson();
-        addField(sb, BATCH, batchSize);
-        addField(sb, MAX_BYTES, maxBytes);
-        addFldWhenTrue(sb, NO_WAIT, noWait);
-        addFieldAsNanos(sb, EXPIRES, expiresIn);
-        addFieldAsNanos(sb, IDLE_HEARTBEAT, idleHeartbeat);
-        return endJson(sb).toString();
+        StringBuilder sb = JsonUtils.beginJson();
+        JsonUtils.addField(sb, BATCH, batchSize);
+        JsonUtils.addField(sb, MAX_BYTES, maxBytes);
+        JsonUtils.addFldWhenTrue(sb, NO_WAIT, noWait);
+        JsonUtils.addFieldAsNanos(sb, EXPIRES, expiresIn);
+        JsonUtils.addFieldAsNanos(sb, IDLE_HEARTBEAT, idleHeartbeat);
+        return JsonUtils.endJson(sb).toString();
     }
 
     /**
diff --git a/src/main/java/io/nats/client/PurgeOptions.java b/src/main/java/io/nats/client/PurgeOptions.java
index 9f265d633..ac9138cbc 100644
--- a/src/main/java/io/nats/client/PurgeOptions.java
+++ b/src/main/java/io/nats/client/PurgeOptions.java
@@ -14,9 +14,11 @@
 package io.nats.client;
 
 import io.nats.client.support.JsonSerializable;
+import io.nats.client.support.JsonUtils;
 
 import static io.nats.client.support.ApiConstants.*;
-import static io.nats.client.support.JsonWriteUtils.*;
+import static io.nats.client.support.JsonUtils.beginJson;
+import static io.nats.client.support.JsonUtils.endJson;
 import static io.nats.client.support.Validator.validateSubject;
 
 /**
@@ -37,9 +39,9 @@ private PurgeOptions(String subject, long seq, long keep) {
     @Override
     public String toJson() {
         StringBuilder sb = beginJson();
-        addField(sb, FILTER, subject);
-        addField(sb, SEQ, seq);
-        addField(sb, KEEP, keep);
+        JsonUtils.addField(sb, FILTER, subject);
+        JsonUtils.addField(sb, SEQ, seq);
+        JsonUtils.addField(sb, KEEP, keep);
         return endJson(sb).toString();
     }
 
diff --git a/src/main/java/io/nats/client/api/ApiResponse.java b/src/main/java/io/nats/client/api/ApiResponse.java
index de6b2f36f..2dbe3a348 100644
--- a/src/main/java/io/nats/client/api/ApiResponse.java
+++ b/src/main/java/io/nats/client/api/ApiResponse.java
@@ -15,16 +15,12 @@
 
 import io.nats.client.JetStreamApiException;
 import io.nats.client.Message;
-import io.nats.client.support.JsonParseException;
-import io.nats.client.support.JsonParser;
-import io.nats.client.support.JsonValue;
-import io.nats.client.support.JsonValueUtils;
+import io.nats.client.support.*;
 
 import static io.nats.client.support.ApiConstants.ERROR;
 import static io.nats.client.support.ApiConstants.TYPE;
 import static io.nats.client.support.JsonValueUtils.readString;
 import static io.nats.client.support.JsonValueUtils.readValue;
-import static io.nats.client.support.JsonWriteUtils.toKey;
 
 public abstract class ApiResponse<T> {
 
@@ -122,6 +118,8 @@ public Error getErrorObject() {
 
     @Override
     public String toString() {
-        return jv == null ? toKey(getClass()) + "\":null" : jv.toString(getClass());
+        return jv == null
+            ? JsonUtils.toKey(getClass()) + "\":null"
+            : jv.toString(getClass());
     }
 }
diff --git a/src/main/java/io/nats/client/api/ConsumerConfiguration.java b/src/main/java/io/nats/client/api/ConsumerConfiguration.java
index e80ceb02d..e3c093e7e 100644
--- a/src/main/java/io/nats/client/api/ConsumerConfiguration.java
+++ b/src/main/java/io/nats/client/api/ConsumerConfiguration.java
@@ -17,6 +17,7 @@
 import io.nats.client.PushSubscribeOptions;
 import io.nats.client.support.ApiConstants;
 import io.nats.client.support.JsonSerializable;
+import io.nats.client.support.JsonUtils;
 import io.nats.client.support.JsonValue;
 
 import java.time.Duration;
@@ -24,8 +25,9 @@
 import java.util.*;
 
 import static io.nats.client.support.ApiConstants.*;
+import static io.nats.client.support.JsonUtils.beginJson;
+import static io.nats.client.support.JsonUtils.endJson;
 import static io.nats.client.support.JsonValueUtils.*;
-import static io.nats.client.support.JsonWriteUtils.*;
 import static io.nats.client.support.NatsJetStreamClientError.JsConsumerNameDurableMismatch;
 import static io.nats.client.support.Validator.*;
 
@@ -202,39 +204,39 @@ protected ConsumerConfiguration(Builder b)
      */
     public String toJson() {
         StringBuilder sb = beginJson();
-        addField(sb, DESCRIPTION, description);
-        addField(sb, DURABLE_NAME, durable);
-        addField(sb, NAME, name);
-        addField(sb, DELIVER_SUBJECT, deliverSubject);
-        addField(sb, DELIVER_GROUP, deliverGroup);
-        addField(sb, DELIVER_POLICY, GetOrDefault(deliverPolicy).toString());
-        addFieldWhenGtZero(sb, OPT_START_SEQ, startSeq);
-        addField(sb, OPT_START_TIME, startTime);
-        addField(sb, ACK_POLICY, GetOrDefault(ackPolicy).toString());
-        addFieldAsNanos(sb, ACK_WAIT, ackWait);
-        addFieldWhenGtZero(sb, MAX_DELIVER, maxDeliver);
-        addField(sb, MAX_ACK_PENDING, maxAckPending);
-        addField(sb, REPLAY_POLICY, GetOrDefault(replayPolicy).toString());
-        addField(sb, SAMPLE_FREQ, sampleFrequency);
-        addFieldWhenGtZero(sb, RATE_LIMIT_BPS, rateLimit);
-        addFieldAsNanos(sb, IDLE_HEARTBEAT, idleHeartbeat);
-        addFldWhenTrue(sb, FLOW_CONTROL, flowControl);
-        addField(sb, ApiConstants.MAX_WAITING, maxPullWaiting);
-        addFldWhenTrue(sb, HEADERS_ONLY, headersOnly);
-        addField(sb, MAX_BATCH, maxBatch);
-        addField(sb, MAX_BYTES, maxBytes);
-        addFieldAsNanos(sb, MAX_EXPIRES, maxExpires);
-        addFieldAsNanos(sb, INACTIVE_THRESHOLD, inactiveThreshold);
-        addDurations(sb, BACKOFF, backoff);
-        addField(sb, NUM_REPLICAS, numReplicas);
-        addField(sb, MEM_STORAGE, memStorage);
-        addField(sb, METADATA, metadata);
+        JsonUtils.addField(sb, DESCRIPTION, description);
+        JsonUtils.addField(sb, DURABLE_NAME, durable);
+        JsonUtils.addField(sb, NAME, name);
+        JsonUtils.addField(sb, DELIVER_SUBJECT, deliverSubject);
+        JsonUtils.addField(sb, DELIVER_GROUP, deliverGroup);
+        JsonUtils.addField(sb, DELIVER_POLICY, GetOrDefault(deliverPolicy).toString());
+        JsonUtils.addFieldWhenGtZero(sb, OPT_START_SEQ, startSeq);
+        JsonUtils.addField(sb, OPT_START_TIME, startTime);
+        JsonUtils.addField(sb, ACK_POLICY, GetOrDefault(ackPolicy).toString());
+        JsonUtils.addFieldAsNanos(sb, ACK_WAIT, ackWait);
+        JsonUtils.addFieldWhenGtZero(sb, MAX_DELIVER, maxDeliver);
+        JsonUtils.addField(sb, MAX_ACK_PENDING, maxAckPending);
+        JsonUtils.addField(sb, REPLAY_POLICY, GetOrDefault(replayPolicy).toString());
+        JsonUtils.addField(sb, SAMPLE_FREQ, sampleFrequency);
+        JsonUtils.addFieldWhenGtZero(sb, RATE_LIMIT_BPS, rateLimit);
+        JsonUtils.addFieldAsNanos(sb, IDLE_HEARTBEAT, idleHeartbeat);
+        JsonUtils.addFldWhenTrue(sb, FLOW_CONTROL, flowControl);
+        JsonUtils.addField(sb, ApiConstants.MAX_WAITING, maxPullWaiting);
+        JsonUtils.addFldWhenTrue(sb, HEADERS_ONLY, headersOnly);
+        JsonUtils.addField(sb, MAX_BATCH, maxBatch);
+        JsonUtils.addField(sb, MAX_BYTES, maxBytes);
+        JsonUtils.addFieldAsNanos(sb, MAX_EXPIRES, maxExpires);
+        JsonUtils.addFieldAsNanos(sb, INACTIVE_THRESHOLD, inactiveThreshold);
+        JsonUtils.addDurations(sb, BACKOFF, backoff);
+        JsonUtils.addField(sb, NUM_REPLICAS, numReplicas);
+        JsonUtils.addField(sb, MEM_STORAGE, memStorage);
+        JsonUtils.addField(sb, METADATA, metadata);
         if (filterSubjects != null) {
             if (filterSubjects.size() > 1) {
-                addStrings(sb, FILTER_SUBJECTS, filterSubjects);
+                JsonUtils.addStrings(sb, FILTER_SUBJECTS, filterSubjects);
             }
             else if (filterSubjects.size() == 1) {
-                addField(sb, FILTER_SUBJECT, filterSubjects.get(0));
+                JsonUtils.addField(sb, FILTER_SUBJECT, filterSubjects.get(0));
             }
         }
         return endJson(sb).toString();
diff --git a/src/main/java/io/nats/client/api/ConsumerCreateRequest.java b/src/main/java/io/nats/client/api/ConsumerCreateRequest.java
index 80e339ac7..3b3bb1d88 100644
--- a/src/main/java/io/nats/client/api/ConsumerCreateRequest.java
+++ b/src/main/java/io/nats/client/api/ConsumerCreateRequest.java
@@ -14,10 +14,11 @@
 package io.nats.client.api;
 
 import io.nats.client.support.JsonSerializable;
+import io.nats.client.support.JsonUtils;
 
 import static io.nats.client.support.ApiConstants.CONFIG;
 import static io.nats.client.support.ApiConstants.STREAM_NAME;
-import static io.nats.client.support.JsonWriteUtils.*;
+import static io.nats.client.support.JsonUtils.*;
 
 /**
  * Object used to make a request to create a consumer. Used Internally
@@ -44,7 +45,7 @@ public String toJson() {
         StringBuilder sb = beginJson();
 
         addField(sb, STREAM_NAME, streamName);
-        addField(sb, CONFIG, config);
+        JsonUtils.addField(sb, CONFIG, config);
 
         return endJson(sb).toString();
     }
diff --git a/src/main/java/io/nats/client/api/ConsumerLimits.java b/src/main/java/io/nats/client/api/ConsumerLimits.java
index 188191656..c866fbd7c 100644
--- a/src/main/java/io/nats/client/api/ConsumerLimits.java
+++ b/src/main/java/io/nats/client/api/ConsumerLimits.java
@@ -14,6 +14,7 @@
 package io.nats.client.api;
 
 import io.nats.client.support.JsonSerializable;
+import io.nats.client.support.JsonUtils;
 import io.nats.client.support.JsonValue;
 
 import java.time.Duration;
@@ -21,9 +22,10 @@
 import static io.nats.client.api.ConsumerConfiguration.*;
 import static io.nats.client.support.ApiConstants.INACTIVE_THRESHOLD;
 import static io.nats.client.support.ApiConstants.MAX_ACK_PENDING;
+import static io.nats.client.support.JsonUtils.beginJson;
+import static io.nats.client.support.JsonUtils.endJson;
 import static io.nats.client.support.JsonValueUtils.readInteger;
 import static io.nats.client.support.JsonValueUtils.readNanos;
-import static io.nats.client.support.JsonWriteUtils.*;
 
 /**
  * ConsumerLimits
@@ -64,8 +66,8 @@ public long getMaxAckPending() {
 
     public String toJson() {
         StringBuilder sb = beginJson();
-        addFieldAsNanos(sb, INACTIVE_THRESHOLD, inactiveThreshold);
-        addField(sb, MAX_ACK_PENDING, maxAckPending);
+        JsonUtils.addFieldAsNanos(sb, INACTIVE_THRESHOLD, inactiveThreshold);
+        JsonUtils.addField(sb, MAX_ACK_PENDING, maxAckPending);
         return endJson(sb).toString();
     }
 
diff --git a/src/main/java/io/nats/client/api/External.java b/src/main/java/io/nats/client/api/External.java
index 446052010..178150c1a 100644
--- a/src/main/java/io/nats/client/api/External.java
+++ b/src/main/java/io/nats/client/api/External.java
@@ -19,7 +19,7 @@
 
 import static io.nats.client.support.ApiConstants.API;
 import static io.nats.client.support.ApiConstants.DELIVER;
-import static io.nats.client.support.JsonWriteUtils.*;
+import static io.nats.client.support.JsonUtils.*;
 
 /**
  * External configuration referencing a stream source in another account
diff --git a/src/main/java/io/nats/client/api/MessageDeleteRequest.java b/src/main/java/io/nats/client/api/MessageDeleteRequest.java
index 39c569968..02ffbe34c 100644
--- a/src/main/java/io/nats/client/api/MessageDeleteRequest.java
+++ b/src/main/java/io/nats/client/api/MessageDeleteRequest.java
@@ -17,7 +17,7 @@
 
 import static io.nats.client.support.ApiConstants.NO_ERASE;
 import static io.nats.client.support.ApiConstants.SEQ;
-import static io.nats.client.support.JsonWriteUtils.*;
+import static io.nats.client.support.JsonUtils.*;
 
 /**
  * Object used to make a request for message delete requests.
diff --git a/src/main/java/io/nats/client/api/MessageGetRequest.java b/src/main/java/io/nats/client/api/MessageGetRequest.java
index 7be668cd6..17a2caad3 100644
--- a/src/main/java/io/nats/client/api/MessageGetRequest.java
+++ b/src/main/java/io/nats/client/api/MessageGetRequest.java
@@ -16,7 +16,7 @@
 import io.nats.client.support.JsonSerializable;
 
 import static io.nats.client.support.ApiConstants.*;
-import static io.nats.client.support.JsonWriteUtils.*;
+import static io.nats.client.support.JsonUtils.*;
 
 /**
  * Object used to make a request for special message get requests.
diff --git a/src/main/java/io/nats/client/api/MessageInfo.java b/src/main/java/io/nats/client/api/MessageInfo.java
index 39a4d633a..60374beed 100644
--- a/src/main/java/io/nats/client/api/MessageInfo.java
+++ b/src/main/java/io/nats/client/api/MessageInfo.java
@@ -17,14 +17,14 @@
 import io.nats.client.impl.Headers;
 import io.nats.client.support.DateTimeUtils;
 import io.nats.client.support.IncomingHeadersProcessor;
+import io.nats.client.support.JsonUtils;
 import io.nats.client.support.JsonValue;
 
 import java.time.ZonedDateTime;
 
 import static io.nats.client.support.ApiConstants.*;
-import static io.nats.client.support.HeadersUtils.addHeadersAsField;
+import static io.nats.client.support.JsonUtils.addRawJson;
 import static io.nats.client.support.JsonValueUtils.*;
-import static io.nats.client.support.JsonWriteUtils.*;
 import static io.nats.client.support.NatsJetStreamConstants.*;
 
 /**
@@ -75,7 +75,7 @@ public MessageInfo(Message msg, String streamName, boolean direct) {
                 lastSeq = -1;
             }
             else {
-                lastSeq = safeParseLong(temp, -1);
+                lastSeq = JsonUtils.safeParseLong(temp, -1);
             }
             // these are control headers, not real headers so don't give them to the user.
             headers = new Headers(msgHeaders, true, MESSAGE_INFO_HEADERS);
@@ -160,22 +160,22 @@ public long getLastSeq() {
 
     @Override
     public String toString() {
-        StringBuilder sb = beginJsonPrefixed("\"MessageInfo\":");
-        addField(sb, "direct", direct);
-        addField(sb, "error", getError());
-        addField(sb, SUBJECT, subject);
-        addField(sb, SEQ, seq);
+        StringBuilder sb = JsonUtils.beginJsonPrefixed("\"MessageInfo\":");
+        JsonUtils.addField(sb, "direct", direct);
+        JsonUtils.addField(sb, "error", getError());
+        JsonUtils.addField(sb, SUBJECT, subject);
+        JsonUtils.addField(sb, SEQ, seq);
         if (data == null) {
             addRawJson(sb, DATA, "null");
         }
         else {
-            addField(sb, "data_length", data.length);
+            JsonUtils.addField(sb, "data_length", data.length);
         }
-        addField(sb, TIME, time);
-        addField(sb, STREAM, stream);
-        addField(sb, "last_seq", lastSeq);
-        addField(sb, SUBJECT, subject);
-        addHeadersAsField(sb, HDRS, headers);
-        return endJson(sb).toString();
+        JsonUtils.addField(sb, TIME, time);
+        JsonUtils.addField(sb, STREAM, stream);
+        JsonUtils.addField(sb, "last_seq", lastSeq);
+        JsonUtils.addField(sb, SUBJECT, subject);
+        JsonUtils.addField(sb, HDRS, headers);
+        return JsonUtils.endJson(sb).toString();
     }
 }
diff --git a/src/main/java/io/nats/client/api/ObjectInfo.java b/src/main/java/io/nats/client/api/ObjectInfo.java
index dccb80f6f..7f0123a35 100644
--- a/src/main/java/io/nats/client/api/ObjectInfo.java
+++ b/src/main/java/io/nats/client/api/ObjectInfo.java
@@ -19,7 +19,8 @@
 import java.time.ZonedDateTime;
 
 import static io.nats.client.support.ApiConstants.*;
-import static io.nats.client.support.JsonWriteUtils.*;
+import static io.nats.client.support.JsonUtils.beginJson;
+import static io.nats.client.support.JsonUtils.endJson;
 
 /**
  * The ObjectInfo is Object Meta Information plus instance information
@@ -71,12 +72,12 @@ public String toJson() {
         // never write MTIME (modified)
         StringBuilder sb = beginJson();
         objectMeta.embedJson(sb); // the go code embeds the objectMeta's fields instead of as a child object.
-        addField(sb, BUCKET, bucket);
-        addField(sb, NUID, nuid);
-        addField(sb, SIZE, size);
-        addField(sb, CHUNKS, chunks);
-        addField(sb, DIGEST, digest);
-        addField(sb, DELETED, deleted);
+        JsonUtils.addField(sb, BUCKET, bucket);
+        JsonUtils.addField(sb, NUID, nuid);
+        JsonUtils.addField(sb, SIZE, size);
+        JsonUtils.addField(sb, CHUNKS, chunks);
+        JsonUtils.addField(sb, DIGEST, digest);
+        JsonUtils.addField(sb, DELETED, deleted);
         return endJson(sb).toString();
     }
 
diff --git a/src/main/java/io/nats/client/api/ObjectLink.java b/src/main/java/io/nats/client/api/ObjectLink.java
index 19b9c22fa..346db7b7f 100644
--- a/src/main/java/io/nats/client/api/ObjectLink.java
+++ b/src/main/java/io/nats/client/api/ObjectLink.java
@@ -13,13 +13,15 @@
 package io.nats.client.api;
 
 import io.nats.client.support.JsonSerializable;
+import io.nats.client.support.JsonUtils;
 import io.nats.client.support.JsonValue;
 import io.nats.client.support.Validator;
 
 import static io.nats.client.support.ApiConstants.BUCKET;
 import static io.nats.client.support.ApiConstants.NAME;
+import static io.nats.client.support.JsonUtils.beginJson;
+import static io.nats.client.support.JsonUtils.endJson;
 import static io.nats.client.support.JsonValueUtils.readString;
-import static io.nats.client.support.JsonWriteUtils.*;
 
 /**
  * The ObjectLink is used to embed links to other objects.
@@ -46,8 +48,8 @@ private ObjectLink(String bucket, String objectName) {
     @Override
     public String toJson() {
         StringBuilder sb = beginJson();
-        addField(sb, BUCKET, bucket);
-        addField(sb, NAME, objectName);
+        JsonUtils.addField(sb, BUCKET, bucket);
+        JsonUtils.addField(sb, NAME, objectName);
         return endJson(sb).toString();
     }
 
diff --git a/src/main/java/io/nats/client/api/ObjectMeta.java b/src/main/java/io/nats/client/api/ObjectMeta.java
index ed24bd925..aaf59d273 100644
--- a/src/main/java/io/nats/client/api/ObjectMeta.java
+++ b/src/main/java/io/nats/client/api/ObjectMeta.java
@@ -14,13 +14,14 @@
 
 import io.nats.client.impl.Headers;
 import io.nats.client.support.JsonSerializable;
+import io.nats.client.support.JsonUtils;
 import io.nats.client.support.JsonValue;
 import io.nats.client.support.Validator;
 
 import static io.nats.client.support.ApiConstants.*;
-import static io.nats.client.support.HeadersUtils.addHeadersAsField;
+import static io.nats.client.support.JsonUtils.beginJson;
+import static io.nats.client.support.JsonUtils.endJson;
 import static io.nats.client.support.JsonValueUtils.*;
-import static io.nats.client.support.JsonWriteUtils.*;
 
 /**
  * The ObjectMeta is Object Meta is high level information about an object
@@ -59,14 +60,14 @@ public String toJson() {
     }
 
     void embedJson(StringBuilder sb) {
-        addField(sb, NAME, objectName);
-        addField(sb, DESCRIPTION, description);
-        addHeadersAsField(sb, HEADERS, headers);
+        JsonUtils.addField(sb, NAME, objectName);
+        JsonUtils.addField(sb, DESCRIPTION, description);
+        JsonUtils.addField(sb, HEADERS, headers);
 
-        // avoid adding an empty child to the json because addField
+        // avoid adding an empty child to the json because JsonUtils.addField
         // only checks versus the object being null, which it is never
         if (objectMetaOptions.hasData()) {
-            addField(sb, OPTIONS, objectMetaOptions);
+            JsonUtils.addField(sb, OPTIONS, objectMetaOptions);
         }
     }
 
diff --git a/src/main/java/io/nats/client/api/ObjectMetaOptions.java b/src/main/java/io/nats/client/api/ObjectMetaOptions.java
index 20f621874..f029a020c 100644
--- a/src/main/java/io/nats/client/api/ObjectMetaOptions.java
+++ b/src/main/java/io/nats/client/api/ObjectMetaOptions.java
@@ -13,13 +13,15 @@
 package io.nats.client.api;
 
 import io.nats.client.support.JsonSerializable;
+import io.nats.client.support.JsonUtils;
 import io.nats.client.support.JsonValue;
 
 import static io.nats.client.support.ApiConstants.LINK;
 import static io.nats.client.support.ApiConstants.MAX_CHUNK_SIZE;
+import static io.nats.client.support.JsonUtils.beginJson;
+import static io.nats.client.support.JsonUtils.endJson;
 import static io.nats.client.support.JsonValueUtils.readInteger;
 import static io.nats.client.support.JsonValueUtils.readValue;
-import static io.nats.client.support.JsonWriteUtils.*;
 
 /**
  * The ObjectMeta is Object Meta is high level information about an object.
@@ -42,8 +44,8 @@ private ObjectMetaOptions(Builder b) {
     @Override
     public String toJson() {
         StringBuilder sb = beginJson();
-        addField(sb, LINK, link);
-        addField(sb, MAX_CHUNK_SIZE, chunkSize);
+        JsonUtils.addField(sb, LINK, link);
+        JsonUtils.addField(sb, MAX_CHUNK_SIZE, chunkSize);
         return endJson(sb).toString();
     }
 
diff --git a/src/main/java/io/nats/client/api/Placement.java b/src/main/java/io/nats/client/api/Placement.java
index 2473ec971..e90bc6291 100644
--- a/src/main/java/io/nats/client/api/Placement.java
+++ b/src/main/java/io/nats/client/api/Placement.java
@@ -22,9 +22,9 @@
 
 import static io.nats.client.support.ApiConstants.CLUSTER;
 import static io.nats.client.support.ApiConstants.TAGS;
+import static io.nats.client.support.JsonUtils.*;
 import static io.nats.client.support.JsonValueUtils.readOptionalStringList;
 import static io.nats.client.support.JsonValueUtils.readString;
-import static io.nats.client.support.JsonWriteUtils.*;
 
 /**
  * Placement directives to consider when placing replicas of a stream
diff --git a/src/main/java/io/nats/client/api/Republish.java b/src/main/java/io/nats/client/api/Republish.java
index 7e9eb3503..dcf5832c4 100644
--- a/src/main/java/io/nats/client/api/Republish.java
+++ b/src/main/java/io/nats/client/api/Republish.java
@@ -18,9 +18,9 @@
 import io.nats.client.support.Validator;
 
 import static io.nats.client.support.ApiConstants.*;
+import static io.nats.client.support.JsonUtils.*;
 import static io.nats.client.support.JsonValueUtils.readBoolean;
 import static io.nats.client.support.JsonValueUtils.readString;
-import static io.nats.client.support.JsonWriteUtils.*;
 
 /**
  * Republish Configuration
diff --git a/src/main/java/io/nats/client/api/SourceBase.java b/src/main/java/io/nats/client/api/SourceBase.java
index c8e786847..6102e5e01 100644
--- a/src/main/java/io/nats/client/api/SourceBase.java
+++ b/src/main/java/io/nats/client/api/SourceBase.java
@@ -14,6 +14,7 @@
 package io.nats.client.api;
 
 import io.nats.client.support.JsonSerializable;
+import io.nats.client.support.JsonUtils;
 import io.nats.client.support.JsonValue;
 import io.nats.client.support.JsonValueUtils;
 
@@ -25,8 +26,9 @@
 
 import static io.nats.client.JetStreamOptions.convertDomainToPrefix;
 import static io.nats.client.support.ApiConstants.*;
+import static io.nats.client.support.JsonUtils.beginJson;
+import static io.nats.client.support.JsonUtils.endJson;
 import static io.nats.client.support.JsonValueUtils.readValue;
-import static io.nats.client.support.JsonWriteUtils.*;
 import static io.nats.client.support.Validator.consumerFilterSubjectsAreEquivalent;
 
 public abstract class SourceBase implements JsonSerializable {
@@ -62,12 +64,12 @@ public abstract class SourceBase implements JsonSerializable {
      */
     public String toJson() {
         StringBuilder sb = beginJson();
-        addField(sb, NAME, name);
-        addFieldWhenGreaterThan(sb, OPT_START_SEQ, startSeq, 0);
-        addField(sb, OPT_START_TIME, startTime);
-        addField(sb, FILTER_SUBJECT, filterSubject);
-        addField(sb, EXTERNAL, external);
-        addJsons(sb, SUBJECT_TRANSFORMS, subjectTransforms);
+        JsonUtils.addField(sb, NAME, name);
+        JsonUtils.addFieldWhenGreaterThan(sb, OPT_START_SEQ, startSeq, 0);
+        JsonUtils.addField(sb, OPT_START_TIME, startTime);
+        JsonUtils.addField(sb, FILTER_SUBJECT, filterSubject);
+        JsonUtils.addField(sb, EXTERNAL, external);
+        JsonUtils.addJsons(sb, SUBJECT_TRANSFORMS, subjectTransforms);
         return endJson(sb).toString();
     }
 
@@ -109,7 +111,7 @@ public List<SubjectTransform> getSubjectTransforms() {
 
     @Override
     public String toString() {
-        return toKey(getClass()) + toJson();
+        return JsonUtils.toKey(getClass()) + toJson();
     }
 
     public abstract static class SourceBaseBuilder<T> {
diff --git a/src/main/java/io/nats/client/api/StreamConfiguration.java b/src/main/java/io/nats/client/api/StreamConfiguration.java
index 5a30f0a67..20ee6b175 100644
--- a/src/main/java/io/nats/client/api/StreamConfiguration.java
+++ b/src/main/java/io/nats/client/api/StreamConfiguration.java
@@ -13,17 +13,19 @@
 
 package io.nats.client.api;
 
-import io.nats.client.support.JsonParseException;
-import io.nats.client.support.JsonParser;
-import io.nats.client.support.JsonSerializable;
-import io.nats.client.support.JsonValue;
+import io.nats.client.support.*;
 
 import java.time.Duration;
 import java.util.*;
 
 import static io.nats.client.support.ApiConstants.*;
+import static io.nats.client.support.JsonUtils.*;
+import static io.nats.client.support.JsonValueUtils.readBoolean;
+import static io.nats.client.support.JsonValueUtils.readInteger;
+import static io.nats.client.support.JsonValueUtils.readLong;
+import static io.nats.client.support.JsonValueUtils.readNanos;
+import static io.nats.client.support.JsonValueUtils.readString;
 import static io.nats.client.support.JsonValueUtils.*;
-import static io.nats.client.support.JsonWriteUtils.*;
 import static io.nats.client.support.Validator.*;
 
 /**
@@ -161,7 +163,7 @@ public String toJson() {
         StringBuilder sb = beginJson();
 
         addField(sb, NAME, name);
-        addField(sb, DESCRIPTION, description);
+        JsonUtils.addField(sb, DESCRIPTION, description);
         addStrings(sb, SUBJECTS, subjects);
         addField(sb, RETENTION, retentionPolicy.toString());
         addEnumWhenNot(sb, COMPRESSION, compressionOption, CompressionOption.None);
diff --git a/src/main/java/io/nats/client/api/StreamInfoOptions.java b/src/main/java/io/nats/client/api/StreamInfoOptions.java
index fe383736e..1df84ea88 100644
--- a/src/main/java/io/nats/client/api/StreamInfoOptions.java
+++ b/src/main/java/io/nats/client/api/StreamInfoOptions.java
@@ -17,7 +17,7 @@
 
 import static io.nats.client.support.ApiConstants.DELETED_DETAILS;
 import static io.nats.client.support.ApiConstants.SUBJECTS_FILTER;
-import static io.nats.client.support.JsonWriteUtils.*;
+import static io.nats.client.support.JsonUtils.*;
 import static io.nats.client.support.Validator.emptyAsNull;
 
 /**
diff --git a/src/main/java/io/nats/client/api/SubjectTransform.java b/src/main/java/io/nats/client/api/SubjectTransform.java
index a08077dfc..4fb58c3a4 100644
--- a/src/main/java/io/nats/client/api/SubjectTransform.java
+++ b/src/main/java/io/nats/client/api/SubjectTransform.java
@@ -22,8 +22,8 @@
 
 import static io.nats.client.support.ApiConstants.DEST;
 import static io.nats.client.support.ApiConstants.SRC;
+import static io.nats.client.support.JsonUtils.*;
 import static io.nats.client.support.JsonValueUtils.readString;
-import static io.nats.client.support.JsonWriteUtils.*;
 
 /**
  * SubjectTransform
diff --git a/src/main/java/io/nats/client/impl/StreamInfoReader.java b/src/main/java/io/nats/client/impl/StreamInfoReader.java
index b2878247e..4b5a3aa61 100644
--- a/src/main/java/io/nats/client/impl/StreamInfoReader.java
+++ b/src/main/java/io/nats/client/impl/StreamInfoReader.java
@@ -20,7 +20,7 @@
 
 import static io.nats.client.support.ApiConstants.DELETED_DETAILS;
 import static io.nats.client.support.ApiConstants.SUBJECTS_FILTER;
-import static io.nats.client.support.JsonWriteUtils.*;
+import static io.nats.client.support.JsonUtils.*;
 
 class StreamInfoReader {
 
diff --git a/src/main/java/io/nats/client/support/DateTimeUtils.java b/src/main/java/io/nats/client/support/DateTimeUtils.java
new file mode 100644
index 000000000..c84f53ac6
--- /dev/null
+++ b/src/main/java/io/nats/client/support/DateTimeUtils.java
@@ -0,0 +1,80 @@
+// Copyright 2020 The NATS Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at:
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package io.nats.client.support;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+
+/**
+ * Internal json parsing helpers.
+ */
+public abstract class DateTimeUtils {
+    private DateTimeUtils() {}  /* ensures cannot be constructed */
+
+    public static final ZoneId ZONE_ID_GMT = ZoneId.of("GMT");
+    public static final ZonedDateTime DEFAULT_TIME = ZonedDateTime.of(1, 1, 1, 0, 0, 0, 0, ZONE_ID_GMT);
+    public static final DateTimeFormatter RFC3339_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.nnnnnnnnn'Z'");
+
+    public static ZonedDateTime toGmt(ZonedDateTime zonedDateTime) {
+        return zonedDateTime.withZoneSameInstant(ZONE_ID_GMT);
+    }
+
+    public static ZonedDateTime gmtNow() {
+        return ZonedDateTime.now().withZoneSameInstant(ZONE_ID_GMT);
+    }
+
+    public static boolean equals(ZonedDateTime zdt1, ZonedDateTime zdt2) {
+        if (zdt1 == zdt2) return true;
+        if (zdt1 == null || zdt2 == null) return false;
+        return zdt1.withZoneSameInstant(ZONE_ID_GMT).equals(zdt2.withZoneSameInstant(ZONE_ID_GMT));
+    }
+
+    public static String toRfc3339(ZonedDateTime zonedDateTime) {
+        return RFC3339_FORMATTER.format(toGmt(zonedDateTime));
+    }
+
+    /**
+     * Parses a date time from the server.
+     * @param dateTime - date time from the server.
+     * @return a Zoned Date time.
+     */
+    public static ZonedDateTime parseDateTime(String dateTime) {
+        return parseDateTime(dateTime, DEFAULT_TIME);
+    }
+
+    public static ZonedDateTime parseDateTime(String dateTime, ZonedDateTime dflt) {
+        try {
+            return toGmt(ZonedDateTime.parse(dateTime));
+        }
+        catch (DateTimeParseException s) {
+            return dflt;
+        }
+    }
+
+    public static ZonedDateTime parseDateTimeThrowParseError(String dateTime) {
+        return toGmt(ZonedDateTime.parse(dateTime));
+    }
+
+    public static ZonedDateTime fromNow(long millis) {
+        return ZonedDateTime.ofInstant(Instant.now().plusMillis(millis), ZONE_ID_GMT);
+    }
+
+    public static ZonedDateTime fromNow(Duration dur) {
+        return ZonedDateTime.ofInstant(Instant.now().plusMillis(dur.toMillis()), ZONE_ID_GMT);
+    }
+}
diff --git a/src/main/java/io/nats/client/support/Encoding.java b/src/main/java/io/nats/client/support/Encoding.java
new file mode 100644
index 000000000..ab348bab5
--- /dev/null
+++ b/src/main/java/io/nats/client/support/Encoding.java
@@ -0,0 +1,265 @@
+// Copyright 2020 The NATS Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at:
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package io.nats.client.support;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Base64;
+
+public abstract class Encoding {
+    private Encoding() {}  /* ensures cannot be constructed */
+
+    /**
+     * base64 url encode a byte array to a byte array
+     * @param input the input byte array to encode
+     * @return the encoded byte array
+     * @deprecated prefer base64UrlEncode
+     */
+    @Deprecated
+    public static byte[] base64Encode(byte[] input) {
+        return Base64.getUrlEncoder().withoutPadding().encode(input);
+    }
+
+    /**
+     * base64 url encode a byte array to a byte array
+     * @param input the input byte array to encode
+     * @return the encoded byte array
+     */
+    public static byte[] base64UrlEncode(byte[] input) {
+        return Base64.getUrlEncoder().withoutPadding().encode(input);
+    }
+
+    /**
+     * base64 url encode a byte array to a string
+     * @param input the input byte array to encode
+     * @return the encoded string
+     */
+    public static String toBase64Url(byte[] input) {
+        return new String(base64UrlEncode(input));
+    }
+
+    /**
+     * base64 url encode a string to a string
+     * @param input the input string to encode
+     * @return the encoded string
+     */
+    public static String toBase64Url(String input) {
+        return new String(base64UrlEncode(input.getBytes(StandardCharsets.US_ASCII)));
+    }
+
+    /**
+     * base64 url decode a byte array
+     * @param input the input byte array to decode
+     * @return the decoded byte array
+     */
+    public static byte[] base64UrlDecode(byte[] input) {
+        return Base64.getUrlDecoder().decode(input);
+    }
+
+    /**
+     * get a string from a base64 url encoded byte array
+     * @param input the input string to decode
+     * @return the decoded string
+     */
+    public static String fromBase64Url(String input) {
+        return new String(base64UrlDecode(input.getBytes(StandardCharsets.US_ASCII)));
+    }
+
+    // http://en.wikipedia.org/wiki/Base_32
+    private static final String BASE32_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
+    private static final int[] BASE32_LOOKUP;
+    private static final int MASK = 31;
+    private static final int SHIFT = 5;
+    public static char[] base32Encode(final byte[] input) {
+        int last = input.length;
+        char[] charBuff = new char[(last + 7) * 8 / SHIFT];
+        int offset = 0;
+        int buffer = input[offset++];
+        int bitsLeft = 8;
+        int i = 0;
+
+        while (bitsLeft > 0 || offset < last) {
+            if (bitsLeft < SHIFT) {
+                if (offset < last) {
+                    buffer <<= 8;
+                    buffer |= (input[offset++] & 0xff);
+                    bitsLeft += 8;
+                } else {
+                    int pad = SHIFT - bitsLeft;
+                    buffer <<= pad;
+                    bitsLeft += pad;
+                }
+            }
+            int index = MASK & (buffer >> (bitsLeft - SHIFT));
+            bitsLeft -= SHIFT;
+            charBuff[i] = BASE32_CHARS.charAt(index);
+            i++;
+        }
+
+        int nonBlank;
+
+        for (nonBlank=charBuff.length-1;nonBlank>=0;nonBlank--) {
+            if (charBuff[nonBlank] != 0) {
+                break;
+            }
+        }
+
+        char[] retVal = new char[nonBlank+1];
+
+        System.arraycopy(charBuff, 0, retVal, 0, retVal.length);
+
+        Arrays.fill(charBuff, '\0');
+
+        return retVal;
+    }
+    static {
+        BASE32_LOOKUP = new int[256];
+
+        Arrays.fill(BASE32_LOOKUP, 0xFF);
+
+        for (int i = 0; i < BASE32_CHARS.length(); i++) {
+            int index = BASE32_CHARS.charAt(i) - '0';
+            BASE32_LOOKUP[index] = i;
+        }
+    }
+
+    public static byte[] base32Decode(final char[] input) {
+        byte[] bytes = new byte[input.length * SHIFT / 8];
+        int buffer = 0;
+        int next = 0;
+        int bitsLeft = 0;
+
+        for (int i = 0; i < input.length; i++) {
+            int lookup = input[i] - '0';
+
+            if (lookup < 0 || lookup >= BASE32_LOOKUP.length) {
+                continue;
+            }
+
+            int c = BASE32_LOOKUP[lookup];
+            buffer <<= SHIFT;
+            buffer |= c & MASK;
+            bitsLeft += SHIFT;
+            if (bitsLeft >= 8) {
+                bytes[next++] = (byte) (buffer >> (bitsLeft - 8));
+                bitsLeft -= 8;
+            }
+        }
+        return bytes;
+    }
+
+    public static String jsonDecode(String s) {
+        int len = s.length();
+        StringBuilder sb = new StringBuilder(len);
+        for (int x = 0; x < len; x++) {
+            char ch = s.charAt(x);
+            if (ch == '\\') {
+                char nextChar = (x == len - 1) ? '\\' : s.charAt(x + 1);
+                switch (nextChar) {
+                    case '\\':
+                        ch = '\\';
+                        break;
+                    case 'b':
+                        ch = '\b';
+                        break;
+                    case 'f':
+                        ch = '\f';
+                        break;
+                    case 'n':
+                        ch = '\n';
+                        break;
+                    case 'r':
+                        ch = '\r';
+                        break;
+                    case 't':
+                        ch = '\t';
+                        break;
+                    // Hex Unicode: u????
+                    case 'u':
+                        if (x >= len - 5) {
+                            ch = 'u';
+                            break;
+                        }
+                        int code = Integer.parseInt(
+                            "" + s.charAt(x + 2) + s.charAt(x + 3) + s.charAt(x + 4) + s.charAt(x + 5), 16);
+                        sb.append(Character.toChars(code));
+                        x += 5;
+                        continue;
+                    default:
+                        ch = nextChar;
+                        break;
+                }
+                x++;
+            }
+            sb.append(ch);
+        }
+        return sb.toString();
+    }
+
+    public static String jsonEncode(String s) {
+        return jsonEncode(new StringBuilder(), s).toString();
+    }
+
+    public static StringBuilder jsonEncode(StringBuilder sb, String s) {
+        int len = s.length();
+        for (int x = 0; x < len; x++) {
+            char ch = s.charAt(x);
+            switch (ch) {
+                case '"':
+                    sb.append("\\\"");
+                    break;
+                case '\\':
+                    sb.append("\\\\");
+                    break;
+                case '\b':
+                    sb.append("\\b");
+                    break;
+                case '\f':
+                    sb.append("\\f");
+                    break;
+                case '\n':
+                    sb.append("\\n");
+                    break;
+                case '\r':
+                    sb.append("\\r");
+                    break;
+                case '\t':
+                    sb.append("\\t");
+                    break;
+                case '/':
+                    sb.append("\\/");
+                    break;
+                default:
+                    if (ch < ' ') {
+                        sb.append(String.format("\\u%04x", (int) ch));
+                    }
+                    else {
+                        sb.append(ch);
+                    }
+                    break;
+            }
+        }
+        return sb;
+    }
+
+    public static String uriDecode(String source) {
+        try {
+            return URLDecoder.decode(source.replace("+", "%2B"), "UTF-8");
+        } catch (UnsupportedEncodingException e) {
+            return source;
+        }
+    }
+}
diff --git a/src/main/java/io/nats/client/support/HeadersUtils.java b/src/main/java/io/nats/client/support/HeadersUtils.java
deleted file mode 100644
index e6f022980..000000000
--- a/src/main/java/io/nats/client/support/HeadersUtils.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright 2024 The NATS Authors
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at:
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package io.nats.client.support;
-
-import io.nats.client.impl.Headers;
-
-import java.util.List;
-import java.util.Map;
-
-import static io.nats.client.support.JsonWriteUtils.*;
-
-public abstract class HeadersUtils {
-
-    public static void addHeadersAsField(StringBuilder sb, String fname, Headers headers) {
-        if (headers != null && !headers.isEmpty()) {
-            sb.append(Q);
-            Encoding.jsonEncode(sb, fname);
-            sb.append("\":{");
-            for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
-                addStrings(sb, entry.getKey(), entry.getValue());
-            }
-            endJson(sb);
-            sb.append(",");
-        }
-    }
-}
diff --git a/src/main/java/io/nats/client/support/JsonParseException.java b/src/main/java/io/nats/client/support/JsonParseException.java
new file mode 100644
index 000000000..2036d46cb
--- /dev/null
+++ b/src/main/java/io/nats/client/support/JsonParseException.java
@@ -0,0 +1,17 @@
+package io.nats.client.support;
+
+import java.io.IOException;
+
+public class JsonParseException extends IOException {
+    public JsonParseException(String message) {
+        super(message);
+    }
+
+    public JsonParseException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public JsonParseException(Throwable cause) {
+        super(cause);
+    }
+}
diff --git a/src/main/java/io/nats/client/support/JsonParser.java b/src/main/java/io/nats/client/support/JsonParser.java
new file mode 100644
index 000000000..9d4538e61
--- /dev/null
+++ b/src/main/java/io/nats/client/support/JsonParser.java
@@ -0,0 +1,439 @@
+// Copyright 2023 The NATS Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at:
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package io.nats.client.support;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static io.nats.client.support.JsonValue.NULL;
+
+public class JsonParser {
+
+    enum Option {KEEP_NULLS}
+
+    public static JsonValue parse(char[] json) throws JsonParseException {
+        return new JsonParser(json, 0).parse();
+    }
+
+    public static JsonValue parse(char[] json, int startIndex) throws JsonParseException {
+        return new JsonParser(json, startIndex).parse();
+    }
+
+    public static JsonValue parse(char[] json, Option... options) throws JsonParseException {
+        return new JsonParser(json, 0, options).parse();
+    }
+
+    public static JsonValue parse(char[] json, int startIndex, Option... options) throws JsonParseException {
+        return new JsonParser(json, startIndex, options).parse();
+    }
+
+    public static JsonValue parse(String json) throws JsonParseException {
+        return new JsonParser(json.toCharArray(), 0).parse();
+    }
+
+    public static JsonValue parse(String json, int startIndex) throws JsonParseException {
+        return new JsonParser(json.toCharArray(), startIndex).parse();
+    }
+
+    public static JsonValue parse(String json, Option... options) throws JsonParseException {
+        return new JsonParser(json.toCharArray(), 0, options).parse();
+    }
+
+    public static JsonValue parse(String json, int startIndex, Option... options) throws JsonParseException {
+        return new JsonParser(json.toCharArray(), startIndex, options).parse();
+    }
+
+    public static JsonValue parse(byte[] json) throws JsonParseException {
+        return new JsonParser(new String(json, StandardCharsets.UTF_8).toCharArray(), 0).parse();
+    }
+
+    public static JsonValue parse(byte[] json, Option... options) throws JsonParseException {
+        return new JsonParser(new String(json, StandardCharsets.UTF_8).toCharArray(), 0, options).parse();
+    }
+
+    public static JsonValue parseUnchecked(char[] json) {
+        try { return parse(json); }
+        catch (JsonParseException j) { throw new RuntimeException(j); }
+    }
+
+    public static JsonValue parseUnchecked(char[] json, int startIndex) {
+        try { return parse(json, startIndex); }
+        catch (JsonParseException j) { throw new RuntimeException(j); }
+    }
+
+    public static JsonValue parseUnchecked(char[] json, Option... options) {
+        try { return parse(json, options); }
+        catch (JsonParseException j) { throw new RuntimeException(j); }
+    }
+
+    public static JsonValue parseUnchecked(char[] json, int startIndex, Option... options) {
+        try { return parse(json, startIndex, options); }
+        catch (JsonParseException j) { throw new RuntimeException(j); }
+    }
+
+    public static JsonValue parseUnchecked(String json) {
+        try { return parse(json); }
+        catch (JsonParseException j) { throw new RuntimeException(j); }
+    }
+
+    public static JsonValue parseUnchecked(String json, int startIndex) {
+        try { return parse(json, startIndex); }
+        catch (JsonParseException j) { throw new RuntimeException(j); }
+    }
+
+    public static JsonValue parseUnchecked(String json, Option... options) {
+        try { return parse(json, options); }
+        catch (JsonParseException j) { throw new RuntimeException(j); }
+    }
+
+    public static JsonValue parseUnchecked(String json, int startIndex, Option... options) {
+        try { return parse(json, startIndex, options); }
+        catch (JsonParseException j) { throw new RuntimeException(j); }
+    }
+
+    public static JsonValue parseUnchecked(byte[] json) {
+        try { return parse(json); }
+        catch (JsonParseException j) { throw new RuntimeException(j); }
+    }
+
+    public static JsonValue parseUnchecked(byte[] json, Option... options) {
+        try { return parse(json, options); }
+        catch (JsonParseException j) { throw new RuntimeException(j); }
+    }
+
+    private final char[] json;
+    private final boolean keepNulls;
+    private final int len;
+    private int idx;
+    private int nextIdx;
+    private char previous;
+    private char current;
+    private char next;
+
+    public JsonParser(char[] json) {
+        this(json, 0);
+    }
+
+    public JsonParser(char[] json, Option... options) {
+        this(json, 0, options);
+    }
+
+    public JsonParser(char[] json, int startIndex, Option... options) {
+        this.json = json;
+
+        boolean kn = false;
+        for (Option o : options) {
+            if (o == Option.KEEP_NULLS) {
+                kn = true;
+                break; // b/c only option currently
+            }
+        }
+        keepNulls = kn;
+
+        len = json == null ? 0 : json.length;
+        idx = startIndex;
+        if (startIndex < 0) {
+            throw new IllegalArgumentException("Invalid start index.");
+        }
+        nextIdx = -1;
+        previous = 0;
+        current = 0;
+        next = 0;
+    }
+
+    public JsonValue parse() throws JsonParseException {
+        char c = peekToken();
+        if (c == 0) {
+            return NULL;
+        }
+        return nextValue();
+    }
+
+    private JsonValue nextValue() throws JsonParseException {
+        char c = peekToken();
+        if (c == 0) {
+            throw new JsonParseException("Unexpected end of data.");
+        }
+        if (c == '"') {
+            nextToken();
+            return new JsonValue(nextString());
+        }
+        if (c == '{') {
+            nextToken();
+            return new JsonValue(nextObject());
+        }
+        if (c == '[') {
+            nextToken();
+            return new JsonValue(nextArray());
+        }
+        return nextPrimitiveValue();
+    }
+
+    private List<JsonValue> nextArray() throws JsonParseException {
+        List<JsonValue> list = new ArrayList<>();
+        char p = peekToken();
+        while (p != ']') {
+            if (p == ',') {
+                nextToken(); // advance past the peek
+            }
+            else {
+                list.add(nextValue());
+            }
+            p = peekToken();
+        }
+        nextToken(); // advance past the peek
+        return list;
+    }
+
+    private JsonValue nextPrimitiveValue() throws JsonParseException {
+        StringBuilder sb = new StringBuilder();
+        char c = peekToken();
+        while (c >= ' ' && ",:]}/\\\"[{;=#".indexOf(c) < 0) {
+            sb.append(nextToken());
+            c = peekToken();
+        }
+        String string = sb.toString();
+        if ("true".equalsIgnoreCase(string)) {
+            return new JsonValue(Boolean.TRUE);
+        }
+        if ("false".equalsIgnoreCase(string)) {
+            return new JsonValue(Boolean.FALSE);
+        }
+        if ("null".equalsIgnoreCase(string)) {
+            return JsonValue.NULL;
+        }
+        try {
+            return asNumber(string);
+        }
+        catch (Exception e) {
+            throw new JsonParseException("Invalid value.");
+        }
+    }
+
+    // next object assumes you have already seen the starting {
+    private Map<String, JsonValue> nextObject() throws JsonParseException {
+        Map<String, JsonValue> map = new HashMap<>();
+        String key;
+        while (true) {
+            char c = nextToken();
+            switch (c) {
+                case 0:
+                    throw new JsonParseException("Text must end with '}'");
+                case '}':
+                    return map;
+                case '{':
+                case '[':
+                    if (previous == '{') {
+                        throw new JsonParseException("Cannot directly nest another Object or Array.");
+                    }
+                    // fall through
+                default:
+                    key = nextString();
+            }
+
+            c = nextToken();
+            if (c != ':') {
+                throw new JsonParseException("Expected a ':' after a key.");
+            }
+
+            JsonValue value = nextValue();
+            if (value != NULL || keepNulls) {
+                map.put(key, value);
+            }
+
+            switch (nextToken()) {
+                case ',':
+                    if (peekToken() == '}') {
+                        return map; // dangling comma
+                    }
+                    break;
+                case '}':
+                    return map;
+                default:
+                    throw new JsonParseException("Expected a ',' or '}'.");
+            }
+        }
+    }
+
+    private char nextToken() {
+        peekToken();
+        idx = nextIdx;
+        nextIdx = -1;
+        previous = current;
+        current = next;
+        next = 0;
+        return current;
+    }
+
+    private char nextChar() {
+        previous = current;
+        if (idx == len) {
+            current = 0;
+        }
+        else {
+            current = json[idx++];
+        }
+        next = 0;
+        nextIdx = -1;
+        return current;
+    }
+
+    private char peekToken() {
+        if (nextIdx == -1) {
+            nextIdx = idx;
+            next = 0;
+            while (nextIdx < len) {
+                char c = json[nextIdx++];
+                switch (c) {
+                    case ' ':
+                    case '\r':
+                    case '\n':
+                    case '\t':
+                        continue;
+                }
+                return next = c;
+            }
+        }
+        return next;
+    }
+
+    // next string assumes you have already seen the starting quote
+    private String nextString() throws JsonParseException {
+        StringBuilder sb = new StringBuilder();
+        while (true) {
+            char c = nextChar();
+            switch (c) {
+                case 0:
+                case '\n':
+                case '\r':
+                    throw new JsonParseException("Unterminated string.");
+                case '\\':
+                    c = nextChar();
+                    switch (c) {
+                        case 'b':
+                            sb.append('\b');
+                            break;
+                        case 't':
+                            sb.append('\t');
+                            break;
+                        case 'n':
+                            sb.append('\n');
+                            break;
+                        case 'f':
+                            sb.append('\f');
+                            break;
+                        case 'r':
+                            sb.append('\r');
+                            break;
+                        case 'u':
+                            sb.append(parseU());
+                            break;
+                        case '"':
+                        case '\'':
+                        case '\\':
+                        case '/':
+                            sb.append(c);
+                            break;
+                        default:
+                            throw new JsonParseException("Illegal escape.");
+                    }
+                    break;
+                default:
+                    if (c == '"') {
+                        return sb.toString();
+                    }
+                    sb.append(c);
+            }
+        }
+    }
+
+    private char[] parseU() throws JsonParseException {
+        char[] a = new char[4];
+        for (int x = 0; x < 4; x++) {
+            a[x] = nextToken();
+            if (a[x] == 0) {
+                throw new JsonParseException("Illegal escape.");
+            }
+        }
+        try {
+            int code = Integer.parseInt("" + a[0] + a[1] + a[2] + a[3], 16);
+            return Character.toChars(code);
+        }
+        catch (RuntimeException e) {
+            throw new JsonParseException("Illegal escape.", e);
+        }
+    }
+
+    private JsonValue asNumber(String val) throws JsonParseException {
+        char initial = val.charAt(0);
+        if ((initial >= '0' && initial <= '9') || initial == '-') {
+            // decimal representation
+            if (isDecimalNotation(val)) {
+                // Use a BigDecimal all the time to keep the original
+                // representation. BigDecimal doesn't support -0.0, ensure we
+                // keep that by forcing a decimal.
+                try {
+                    BigDecimal bd = new BigDecimal(val);
+                    if(initial == '-' && BigDecimal.ZERO.compareTo(bd)==0) {
+                        return new JsonValue(-0.0);
+                    }
+                    return new JsonValue(bd);
+                } catch (NumberFormatException retryAsDouble) {
+                    // this is to support "Hex Floats" like this: 0x1.0P-1074
+                    try {
+                        double d = Double.parseDouble(val);
+                        if(Double.isNaN(d) || Double.isInfinite(d)) {
+                            throw new JsonParseException("val ["+val+"] is not a valid number.");
+                        }
+                        return new JsonValue(d);
+                    } catch (NumberFormatException ignore) {
+                        throw new JsonParseException("val ["+val+"] is not a valid number.");
+                    }
+                }
+            }
+            // block items like 00 01 etc. Java number parsers treat these as Octal.
+            if(initial == '0' && val.length() > 1) {
+                char at1 = val.charAt(1);
+                if(at1 >= '0' && at1 <= '9') {
+                    throw new JsonParseException("val ["+val+"] is not a valid number.");
+                }
+            } else if (initial == '-' && val.length() > 2) {
+                char at1 = val.charAt(1);
+                char at2 = val.charAt(2);
+                if(at1 == '0' && at2 >= '0' && at2 <= '9') {
+                    throw new JsonParseException("val ["+val+"] is not a valid number.");
+                }
+            }
+            BigInteger bi = new BigInteger(val);
+            if(bi.bitLength() <= 31){
+                return new JsonValue(bi.intValue());
+            }
+            if(bi.bitLength() <= 63){
+                return new JsonValue(bi.longValue());
+            }
+            return new JsonValue(bi);
+        }
+        throw new JsonParseException("val ["+val+"] is not a valid number.");
+    }
+
+    private boolean isDecimalNotation(final String val) {
+        return val.indexOf('.') > -1 || val.indexOf('e') > -1
+            || val.indexOf('E') > -1 || "-0".equals(val);
+    }
+}
diff --git a/src/main/java/io/nats/client/support/JsonSerializable.java b/src/main/java/io/nats/client/support/JsonSerializable.java
new file mode 100644
index 000000000..05db533e7
--- /dev/null
+++ b/src/main/java/io/nats/client/support/JsonSerializable.java
@@ -0,0 +1,28 @@
+// Copyright 2021-2023 The NATS Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at:
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package io.nats.client.support;
+
+import java.nio.charset.StandardCharsets;
+
+public interface JsonSerializable {
+    String toJson();
+
+    default byte[] serialize() {
+        return toJson().getBytes(StandardCharsets.UTF_8);
+    }
+
+    default JsonValue toJsonValue() {
+        return JsonParser.parseUnchecked(toJson());
+    }
+}
diff --git a/src/main/java/io/nats/client/support/JsonUtils.java b/src/main/java/io/nats/client/support/JsonUtils.java
index 82138392f..d7612895e 100644
--- a/src/main/java/io/nats/client/support/JsonUtils.java
+++ b/src/main/java/io/nats/client/support/JsonUtils.java
@@ -25,14 +25,16 @@
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
-import static io.nats.client.support.JsonWriteUtils.*;
+import static io.nats.client.support.DateTimeUtils.DEFAULT_TIME;
+import static io.nats.client.support.Encoding.jsonDecode;
+import static io.nats.client.support.Encoding.jsonEncode;
+import static io.nats.client.support.JsonValueUtils.instance;
 import static io.nats.client.support.NatsConstants.COLON;
 
 /**
- * Internal json reading and writing helpers.
- * @deprecated This class has been extracted to the io.nats.client.support.JsonWriteUtils class in the <a href="https://github.com/nats-io/json.java">json.java</a> library.
+ * Internal json parsing helpers.
+ * Read helpers deprecated Prefer using the {@link JsonParser}
  */
-@Deprecated
 public abstract class JsonUtils {
     public static final String EMPTY_JSON = "{}";
 
@@ -44,6 +46,11 @@ public abstract class JsonUtils {
     private static final String BEFORE_FIELD_RE = "\"";
     private static final String AFTER_FIELD_RE = "\"\\s*:\\s*";
 
+    private static final String Q = "\"";
+    private static final String QCOLONQ = "\":\"";
+    private static final String QCOLON = "\":";
+    private static final String QCOMMA = "\",";
+    private static final String COMMA = ",";
     public static final String OPENQ = "{\"";
     public static final String CLOSE = "}";
 
@@ -52,39 +59,47 @@ private JsonUtils() {} /* ensures cannot be constructed */
     // ----------------------------------------------------------------------------------------------------
     // BUILD A STRING OF JSON
     // ----------------------------------------------------------------------------------------------------
-    @Deprecated
     public static StringBuilder beginJson() {
-        return JsonWriteUtils.beginJson();
+        return new StringBuilder("{");
     }
 
-    @Deprecated
     public static StringBuilder beginArray() {
-        return JsonWriteUtils.beginArray();
+        return new StringBuilder("[");
     }
 
-    @Deprecated
     public static StringBuilder beginJsonPrefixed(String prefix) {
-        return JsonWriteUtils.beginJsonPrefixed(prefix);
+        return prefix == null ? beginJson()
+            : new StringBuilder(prefix).append('{');
     }
 
-    @Deprecated
     public static StringBuilder endJson(StringBuilder sb) {
-        return JsonWriteUtils.endJson(sb);
+        int lastIndex = sb.length() - 1;
+        if (sb.charAt(lastIndex) == ',') {
+            sb.setCharAt(lastIndex, '}');
+            return sb;
+        }
+        sb.append("}");
+        return sb;
     }
 
-    @Deprecated
     public static StringBuilder endArray(StringBuilder sb) {
-        return JsonWriteUtils.endArray(sb);
+        int lastIndex = sb.length() - 1;
+        if (sb.charAt(lastIndex) == ',') {
+            sb.setCharAt(lastIndex, ']');
+            return sb;
+        }
+        sb.append("]");
+        return sb;
     }
 
-    @Deprecated
     public static StringBuilder beginFormattedJson() {
-        return JsonWriteUtils.beginFormattedJson();
+        return new StringBuilder("{\n    ");
     }
 
-    @Deprecated
     public static String endFormattedJson(StringBuilder sb) {
-        return JsonWriteUtils.endFormattedJson(sb);
+        sb.setLength(sb.length()-1);
+        sb.append("\n}");
+        return sb.toString().replaceAll(",", ",\n    ");
     }
 
     /**
@@ -93,9 +108,14 @@ public static String endFormattedJson(StringBuilder sb) {
      * @param fname fieldname
      * @param json raw json
      */
-    @Deprecated
     public static void addRawJson(StringBuilder sb, String fname, String json) {
-        JsonWriteUtils.addRawJson(sb, fname, json);
+        if (json != null && json.length() > 0) {
+            sb.append(Q);
+            jsonEncode(sb, fname);
+            sb.append(QCOLON);
+            sb.append(json);
+            sb.append(COMMA);
+        }
     }
 
     /**
@@ -104,9 +124,14 @@ public static void addRawJson(StringBuilder sb, String fname, String json) {
      * @param fname fieldname
      * @param value field value
      */
-    @Deprecated
     public static void addField(StringBuilder sb, String fname, String value) {
-        JsonWriteUtils.addField(sb, fname, value);
+        if (value != null && value.length() > 0) {
+            sb.append(Q);
+            jsonEncode(sb, fname);
+            sb.append(QCOLONQ);
+            jsonEncode(sb, value);
+            sb.append(QCOMMA);
+        }
     }
 
     /**
@@ -115,9 +140,15 @@ public static void addField(StringBuilder sb, String fname, String value) {
      * @param fname fieldname
      * @param value field value
      */
-    @Deprecated
     public static void addFieldEvenEmpty(StringBuilder sb, String fname, String value) {
-        JsonWriteUtils.addFieldEvenEmpty(sb, fname, value);
+        if (value == null) {
+            value = "";
+        }
+        sb.append(Q);
+        jsonEncode(sb, fname);
+        sb.append(QCOLONQ);
+        jsonEncode(sb, value);
+        sb.append(QCOMMA);
     }
 
     /**
@@ -126,9 +157,12 @@ public static void addFieldEvenEmpty(StringBuilder sb, String fname, String valu
      * @param fname fieldname
      * @param value field value
      */
-    @Deprecated
     public static void addField(StringBuilder sb, String fname, Boolean value) {
-        JsonWriteUtils.addField(sb, fname, value);
+        if (value != null) {
+            sb.append(Q);
+            jsonEncode(sb, fname);
+            sb.append(QCOLON).append(value ? "true" : "false").append(COMMA);
+        }
     }
 
     /**
@@ -137,9 +171,10 @@ public static void addField(StringBuilder sb, String fname, Boolean value) {
      * @param fname fieldname
      * @param value field value
      */
-    @Deprecated
     public static void addFldWhenTrue(StringBuilder sb, String fname, Boolean value) {
-        JsonWriteUtils.addFldWhenTrue(sb, fname, value);
+        if (value != null && value) {
+            addField(sb, fname, true);
+        }
     }
 
     /**
@@ -148,9 +183,12 @@ public static void addFldWhenTrue(StringBuilder sb, String fname, Boolean value)
      * @param fname fieldname
      * @param value field value
      */
-    @Deprecated
     public static void addField(StringBuilder sb, String fname, Integer value) {
-        JsonWriteUtils.addField(sb, fname, value);
+        if (value != null && value >= 0) {
+            sb.append(Q);
+            jsonEncode(sb, fname);
+            sb.append(QCOLON).append(value).append(COMMA);
+        }
     }
 
     /**
@@ -159,9 +197,12 @@ public static void addField(StringBuilder sb, String fname, Integer value) {
      * @param fname fieldname
      * @param value field value
      */
-    @Deprecated
     public static void addFieldWhenGtZero(StringBuilder sb, String fname, Integer value) {
-        JsonWriteUtils.addFieldWhenGtZero(sb, fname, value);
+        if (value != null && value > 0) {
+            sb.append(Q);
+            jsonEncode(sb, fname);
+            sb.append(QCOLON).append(value).append(COMMA);
+        }
     }
 
     /**
@@ -170,9 +211,12 @@ public static void addFieldWhenGtZero(StringBuilder sb, String fname, Integer va
      * @param fname fieldname
      * @param value field value
      */
-    @Deprecated
     public static void addField(StringBuilder sb, String fname, Long value) {
-        JsonWriteUtils.addField(sb, fname, value);
+        if (value != null && value >= 0) {
+            sb.append(Q);
+            jsonEncode(sb, fname);
+            sb.append(QCOLON).append(value).append(COMMA);
+        }
     }
 
     /**
@@ -181,9 +225,12 @@ public static void addField(StringBuilder sb, String fname, Long value) {
      * @param fname fieldname
      * @param value field value
      */
-    @Deprecated
     public static void addFieldWhenGtZero(StringBuilder sb, String fname, Long value) {
-        JsonWriteUtils.addFieldWhenGtZero(sb, fname, value);
+        if (value != null && value > 0) {
+            sb.append(Q);
+            jsonEncode(sb, fname);
+            sb.append(QCOLON).append(value).append(COMMA);
+        }
     }
 
     /**
@@ -192,9 +239,12 @@ public static void addFieldWhenGtZero(StringBuilder sb, String fname, Long value
      * @param fname fieldname
      * @param value field value
      */
-    @Deprecated
     public static void addFieldWhenGteMinusOne(StringBuilder sb, String fname, Long value) {
-        JsonWriteUtils.addFieldWhenGteMinusOne(sb, fname, value);
+        if (value != null && value >= -1) {
+            sb.append(Q);
+            jsonEncode(sb, fname);
+            sb.append(QCOLON).append(value).append(COMMA);
+        }
     }
 
     /**
@@ -204,9 +254,12 @@ public static void addFieldWhenGteMinusOne(StringBuilder sb, String fname, Long
      * @param value field value
      * @param gt the number the value must be greater than
      */
-    @Deprecated
     public static void addFieldWhenGreaterThan(StringBuilder sb, String fname, Long value, long gt) {
-        JsonWriteUtils.addFieldWhenGreaterThan(sb, fname, value, gt);
+        if (value != null && value > gt) {
+            sb.append(Q);
+            jsonEncode(sb, fname);
+            sb.append(QCOLON).append(value).append(COMMA);
+        }
     }
 
     /**
@@ -215,9 +268,12 @@ public static void addFieldWhenGreaterThan(StringBuilder sb, String fname, Long
      * @param fname fieldname
      * @param value duration value
      */
-    @Deprecated
     public static void addFieldAsNanos(StringBuilder sb, String fname, Duration value) {
-        JsonWriteUtils.addFieldAsNanos(sb, fname, value);
+        if (value != null && !value.isZero() && !value.isNegative()) {
+            sb.append(Q);
+            jsonEncode(sb, fname);
+            sb.append(QCOLON).append(value.toNanos()).append(COMMA);
+        }
     }
 
     /**
@@ -226,20 +282,25 @@ public static void addFieldAsNanos(StringBuilder sb, String fname, Duration valu
      * @param fname fieldname
      * @param value JsonSerializable value
      */
-    @Deprecated
     public static void addField(StringBuilder sb, String fname, JsonSerializable value) {
-        JsonWriteUtils.addField(sb, fname, value);
+        if (value != null) {
+            sb.append(Q);
+            jsonEncode(sb, fname);
+            sb.append(QCOLON).append(value.toJson()).append(COMMA);
+        }
     }
 
-    @Deprecated
     public static void addField(StringBuilder sb, String fname, Map<String, String> map) {
-        JsonWriteUtils.addField(sb, fname, map);
+        if (map != null && map.size() > 0) {
+            addField(sb, fname, instance(map));
+        }
     }
 
     @SuppressWarnings("rawtypes")
-    @Deprecated
     public static void addEnumWhenNot(StringBuilder sb, String fname, Enum e, Enum dontAddIfThis) {
-        JsonWriteUtils.addEnumWhenNot(sb, fname, e, dontAddIfThis);
+        if (e != null && e != dontAddIfThis) {
+            addField(sb, fname, e.toString());
+        }
     }
 
     public interface ListAdder<T> {
@@ -254,10 +315,9 @@ public interface ListAdder<T> {
      * @param list value list
      * @param adder implementation to add value, including its quotes if required
      */
-    @Deprecated
     public static <T> void _addList(StringBuilder sb, String fname, List<T> list, ListAdder<T> adder) {
         sb.append(Q);
-        Encoding.jsonEncode(sb, fname);
+        jsonEncode(sb, fname);
         sb.append("\":[");
         for (int i = 0; i < list.size(); i++) {
             if (i > 0) {
@@ -274,9 +334,10 @@ public static <T> void _addList(StringBuilder sb, String fname, List<T> list, Li
      * @param fname fieldname
      * @param strings field value
      */
-    @Deprecated
     public static void addStrings(StringBuilder sb, String fname, String[] strings) {
-        JsonWriteUtils.addStrings(sb, fname, strings);
+        if (strings != null && strings.length > 0) {
+            _addStrings(sb, fname, Arrays.asList(strings));
+        }
     }
 
     /**
@@ -285,9 +346,18 @@ public static void addStrings(StringBuilder sb, String fname, String[] strings)
      * @param fname fieldname
      * @param strings field value
      */
-    @Deprecated
     public static void addStrings(StringBuilder sb, String fname, List<String> strings) {
-        JsonWriteUtils.addStrings(sb, fname, strings);
+        if (strings != null && strings.size() > 0) {
+            _addStrings(sb, fname, strings);
+        }
+    }
+
+    private static void _addStrings(StringBuilder sb, String fname, List<String> strings) {
+        _addList(sb, fname, strings, (sbs, s) -> {
+            sb.append(Q);
+            jsonEncode(sb, s);
+            sb.append(Q);
+        });
     }
 
     /**
@@ -296,9 +366,10 @@ public static void addStrings(StringBuilder sb, String fname, List<String> strin
      * @param fname fieldname
      * @param jsons field value
      */
-    @Deprecated
     public static void addJsons(StringBuilder sb, String fname, List<? extends JsonSerializable> jsons) {
-        JsonWriteUtils.addJsons(sb, fname, jsons);
+        if (jsons != null && !jsons.isEmpty()) {
+            _addList(sb, fname, jsons, (sbs, s) -> sbs.append(s.toJson()));
+        }
     }
 
     /**
@@ -307,9 +378,10 @@ public static void addJsons(StringBuilder sb, String fname, List<? extends JsonS
      * @param fname fieldname
      * @param durations list of durations
      */
-    @Deprecated
     public static void addDurations(StringBuilder sb, String fname, List<Duration> durations) {
-        JsonWriteUtils.addDurations(sb, fname, durations);
+        if (durations != null && durations.size() > 0) {
+            _addList(sb, fname, durations, (sbs, dur) -> sbs.append(dur.toNanos()));
+        }
     }
 
     /**
@@ -318,14 +390,27 @@ public static void addDurations(StringBuilder sb, String fname, List<Duration> d
      * @param fname fieldname
      * @param zonedDateTime field value
      */
-    @Deprecated
     public static void addField(StringBuilder sb, String fname, ZonedDateTime zonedDateTime) {
-        JsonWriteUtils.addField(sb, fname, zonedDateTime);
+        if (zonedDateTime != null && !DEFAULT_TIME.equals(zonedDateTime)) {
+            sb.append(Q);
+            jsonEncode(sb, fname);
+            sb.append(QCOLONQ)
+                .append(DateTimeUtils.toRfc3339(zonedDateTime))
+                .append(QCOMMA);
+        }
     }
 
-    @Deprecated
     public static void addField(StringBuilder sb, String fname, Headers headers) {
-        HeadersUtils.addHeadersAsField(sb, fname, headers);
+        if (headers != null && headers.size() > 0) {
+            sb.append(Q);
+            jsonEncode(sb, fname);
+            sb.append("\":{");
+            for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
+                addStrings(sb, entry.getKey(), entry.getValue());
+            }
+            endJson(sb);
+            sb.append(",");
+        }
     }
 
     // ----------------------------------------------------------------------------------------------------
@@ -336,9 +421,8 @@ public static String normalize(String s) {
         return Character.toString(s.charAt(0)).toUpperCase() + s.substring(1).toLowerCase();
     }
 
-    @Deprecated
     public static String toKey(Class<?> c) {
-        return JsonWriteUtils.toKey(c);
+        return "\"" + c.getSimpleName() + "\":";
     }
 
     @Deprecated
@@ -359,26 +443,80 @@ private static String indent(int level) {
      * @param o the object
      * @return the formatted string
      */
-    @Deprecated
     public static String getFormatted(Object o) {
-        return JsonWriteUtils.getFormatted(o);
+        StringBuilder sb = new StringBuilder();
+        int level = 0;
+        int arrayLevel = 0;
+        boolean lastWasClose = false;
+        boolean indentNext = true;
+        String indent = "";
+        String s = o.toString();
+        for (int x = 0; x < s.length(); x++) {
+            char c = s.charAt(x);
+            if (c == '{') {
+                if (arrayLevel > 0 && lastWasClose) {
+                    sb.append(indent);
+                }
+                sb.append(c).append('\n');
+                indent = indent(++level);
+                indentNext = true;
+                lastWasClose = false;
+            }
+            else if (c == '}') {
+                indent = indent(--level);
+                sb.append('\n').append(indent).append(c);
+                lastWasClose = true;
+            }
+            else if (c == ',') {
+                sb.append(",\n");
+                indentNext = true;
+            }
+            else {
+                if (c == '[') {
+                    arrayLevel++;
+                }
+                else if (c == ']') {
+                    arrayLevel--;
+                }
+                if (indentNext) {
+                    if (c != ' ') {
+                        sb.append(indent).append(c);
+                        indentNext = false;
+                    }
+                }
+                else {
+                    sb.append(c);
+                }
+                lastWasClose = lastWasClose && Character.isWhitespace(c);
+            }
+        }
+        return sb.toString();
     }
 
     public static void printFormatted(Object o) {
-        System.out.println(JsonWriteUtils.getFormatted(o));
+        System.out.println(getFormatted(o));
     }
 
     // ----------------------------------------------------------------------------------------------------
     // SAFE NUMBER PARSING HELPERS
     // ----------------------------------------------------------------------------------------------------
-    @Deprecated
     public static Long safeParseLong(String s) {
-        return JsonWriteUtils.safeParseLong(s);
+        try {
+            return Long.parseLong(s);
+        }
+        catch (Exception e1) {
+            try {
+                return Long.parseUnsignedLong(s);
+            }
+            catch (Exception e2) {
+                return null;
+            }
+        }
     }
 
-    @Deprecated
     public static long safeParseLong(String s, long dflt) {
-        return JsonWriteUtils.safeParseLong(s, dflt);
+        Long l = safeParseLong(s);
+        return l == null ? dflt : l;
     }
 
     // ----------------------------------------------------------------------------------------------------
@@ -639,8 +777,8 @@ private static List<String> toList(String arrayString) {
         String[] raw = arrayString.split(",");
         for (String s : raw) {
             String cleaned = s.trim().replace("\"", "");
-            if (!cleaned.isEmpty()) {
-                list.add(Encoding.jsonDecode(cleaned));
+            if (cleaned.length() > 0) {
+                list.add(jsonDecode(cleaned));
             }
         }
         return list;
@@ -703,7 +841,7 @@ public static String readString(String json, Pattern pattern) {
     @Deprecated
     public static String readString(String json, Pattern pattern, String dflt) {
         Matcher m = pattern.matcher(json);
-        return m.find() ? Encoding.jsonDecode(m.group(1)) : dflt;
+        return m.find() ? jsonDecode(m.group(1)) : dflt;
     }
 
     @Deprecated
@@ -732,7 +870,7 @@ else if (c == '"') {
                     sb.append(c);
                 }
             }
-            return Encoding.jsonDecode(sb.toString());
+            return jsonDecode(sb.toString());
         }
         return dflt;
     }
@@ -834,14 +972,33 @@ public static void readNanos(String json, Pattern pattern, Consumer<Duration> c)
         }
     }
 
-    @Deprecated
     public static <T> boolean listEquals(List<T> l1, List<T> l2)
     {
-        return Validator.listEquals(l1, l2);
+        if (l1 == null)
+        {
+            return l2 == null;
+        }
+
+        if (l2 == null)
+        {
+            return false;
+        }
+
+        return l1.equals(l2);
     }
 
-    @Deprecated
     public static boolean mapEquals(Map<String, String> map1, Map<String, String> map2) {
-        return Validator.mapEquals(map1, map2);
+        if (map1 == null) {
+            return map2 == null;
+        }
+        if (map2 == null || map1.size() != map2.size()) {
+            return false;
+        }
+        for (String key : map1.keySet()) {
+            if (!Objects.equals(map1.get(key), map2.get(key))) {
+                return false;
+            }
+        }
+        return true;
     }
 }
diff --git a/src/main/java/io/nats/client/support/JsonValue.java b/src/main/java/io/nats/client/support/JsonValue.java
new file mode 100644
index 000000000..ba3e699b4
--- /dev/null
+++ b/src/main/java/io/nats/client/support/JsonValue.java
@@ -0,0 +1,275 @@
+// Copyright 2023 The NATS Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at:
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package io.nats.client.support;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.*;
+
+import static io.nats.client.support.JsonUtils.*;
+
+public class JsonValue implements JsonSerializable {
+
+    public enum Type {
+        STRING, BOOL, INTEGER, LONG, DOUBLE, FLOAT, BIG_DECIMAL, BIG_INTEGER, MAP, ARRAY, NULL;
+    }
+
+    private static final char QUOTE = '"';
+    private static final char COMMA = ',';
+    private static final String NULL_STR = "null";
+
+    public static final JsonValue NULL = new JsonValue();
+    public static final JsonValue TRUE = new JsonValue(true);
+    public static final JsonValue FALSE = new JsonValue(false);
+    public static final JsonValue EMPTY_MAP = new JsonValue(Collections.unmodifiableMap(new HashMap<>()));
+    public static final JsonValue EMPTY_ARRAY = new JsonValue(Collections.unmodifiableList(new ArrayList<>()));
+
+    public final String string;
+    public final Boolean bool;
+    public final Integer i;
+    public final Long l;
+    public final Double d;
+    public final Float f;
+    public final BigDecimal bd;
+    public final BigInteger bi;
+    public final Map<String, JsonValue> map;
+    public final List<JsonValue> array;
+    public final Type type;
+    public final Object object;
+    public final Number number;
+
+    public final List<String> mapOrder;
+
+    public JsonValue() {
+        this(null, null, null, null, null, null, null, null, null, null);
+    }
+
+    public JsonValue(String string) {
+        this(string, null, null, null, null, null, null, null, null, null);
+    }
+
+    public JsonValue(char c) {
+        this("" + c, null, null, null, null, null, null, null, null, null);
+    }
+
+    public JsonValue(Boolean bool) {
+        this(null, bool, null, null, null, null, null, null, null, null);
+    }
+
+    public JsonValue(int i) {
+        this(null, null, i, null, null, null, null, null, null, null);
+    }
+
+    public JsonValue(long l) {
+        this(null, null, null, l, null, null, null, null, null, null);
+    }
+
+    public JsonValue(double d) {
+        this(null, null, null, null, d, null, null, null, null, null);
+    }
+
+    public JsonValue(float f) {
+        this(null, null, null, null, null, f, null, null, null, null);
+    }
+
+    public JsonValue(BigDecimal bd) {
+        this(null, null, null, null, null, null, bd, null, null, null);
+    }
+
+    public JsonValue(BigInteger bi) {
+        this(null, null, null, null, null, null, null, bi, null, null);
+    }
+
+    public JsonValue(Map<String, JsonValue> map) {
+        this(null, null, null, null, null, null, null, null, map, null);
+    }
+
+    public JsonValue(List<JsonValue> list) {
+        this(null, null, null, null, null, null, null, null, null, list);
+    }
+
+    public JsonValue(JsonValue[] values) {
+        this(null, null, null, null, null, null, null, null, null, values == null ? null : Arrays.asList(values));
+    }
+
+    private JsonValue(String string, Boolean bool, Integer i, Long l, Double d, Float f, BigDecimal bd, BigInteger bi, Map<String, JsonValue> map, List<JsonValue> array) {
+        this.map = map;
+        mapOrder = new ArrayList<>();
+        this.array = array;
+        this.string = string;
+        this.bool = bool;
+        this.i = i;
+        this.l = l;
+        this.d = d;
+        this.f = f;
+        this.bd = bd;
+        this.bi = bi;
+        if (i != null) {
+            this.type = Type.INTEGER;
+            number = i;
+            object = number;
+        }
+        else if (l != null) {
+            this.type = Type.LONG;
+            number = l;
+            object = number;
+        }
+        else if (d != null) {
+            this.type = Type.DOUBLE;
+            number = this.d;
+            object = number;
+        }
+        else if (f != null) {
+            this.type = Type.FLOAT;
+            number = this.f;
+            object = number;
+        }
+        else if (bd != null) {
+            this.type = Type.BIG_DECIMAL;
+            number = this.bd;
+            object = number;
+        }
+        else if (bi != null) {
+            this.type = Type.BIG_INTEGER;
+            number = this.bi;
+            object = number;
+        }
+        else {
+            number = null;
+            if (map != null) {
+                this.type = Type.MAP;
+                object = map;
+            }
+            else if (string != null) {
+                this.type = Type.STRING;
+                object = string;
+            }
+            else if (bool != null) {
+                this.type = Type.BOOL;
+                object = bool;
+            }
+            else if (array != null) {
+                this.type = Type.ARRAY;
+                object = array;
+            }
+            else {
+                this.type = Type.NULL;
+                object = null;
+            }
+        }
+    }
+
+    public String toString(Class<?> c) {
+        return toString(c.getSimpleName());
+    }
+
+    public String toString(String key) {
+        return QUOTE + key + QUOTE + ":" + toJson();
+    }
+
+    @Override
+    public String toString() {
+        return toJson();
+    }
+
+    @Override
+    public JsonValue toJsonValue() {
+        return this;
+    }
+
+    @Override
+    public String toJson() {
+        switch (type) {
+            case STRING:      return valueString(string);
+            case BOOL:        return valueString(bool);
+            case MAP:         return valueString(map);
+            case ARRAY:       return valueString(array);
+            case INTEGER:     return i.toString();
+            case LONG:        return l.toString();
+            case DOUBLE:      return d.toString();
+            case FLOAT:       return f.toString();
+            case BIG_DECIMAL: return bd.toString();
+            case BIG_INTEGER: return bi.toString();
+            default:          return NULL_STR;
+        }
+    }
+
+    private String valueString(String s) {
+        return QUOTE + Encoding.jsonEncode(s) + QUOTE;
+    }
+
+    private String valueString(boolean b) {
+        return Boolean.toString(b).toLowerCase();
+    }
+
+    private String valueString(Map<String, JsonValue> map) {
+        StringBuilder sbo = beginJson();
+        if (!mapOrder.isEmpty()) {
+            for (String key : mapOrder) {
+                addField(sbo, key, map.get(key));
+            }
+        }
+        else {
+            for (String key : map.keySet()) {
+                addField(sbo, key, map.get(key));
+            }
+        }
+        return endJson(sbo).toString();
+    }
+
+    private String valueString(List<JsonValue> list) {
+        StringBuilder sba = beginArray();
+        for (JsonValue v : list) {
+            sba.append(v.toJson());
+            sba.append(COMMA);
+        }
+        return endArray(sba).toString();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        JsonValue jsonValue = (JsonValue) o;
+
+        if (type != jsonValue.type) return false;
+        if (!Objects.equals(map, jsonValue.map)) return false;
+        if (!Objects.equals(array, jsonValue.array)) return false;
+        if (!Objects.equals(string, jsonValue.string)) return false;
+        if (!Objects.equals(bool, jsonValue.bool)) return false;
+        if (!Objects.equals(i, jsonValue.i)) return false;
+        if (!Objects.equals(l, jsonValue.l)) return false;
+        if (!Objects.equals(d, jsonValue.d)) return false;
+        if (!Objects.equals(f, jsonValue.f)) return false;
+        if (!Objects.equals(bd, jsonValue.bd)) return false;
+        return Objects.equals(bi, jsonValue.bi);
+    }
+
+    @Override
+    public int hashCode() {
+        int result = map != null ? map.hashCode() : 0;
+        result = 31 * result + (array != null ? array.hashCode() : 0);
+        result = 31 * result + (string != null ? string.hashCode() : 0);
+        result = 31 * result + (bool != null ? bool.hashCode() : 0);
+        result = 31 * result + (i != null ? i.hashCode() : 0);
+        result = 31 * result + (l != null ? l.hashCode() : 0);
+        result = 31 * result + (d != null ? d.hashCode() : 0);
+        result = 31 * result + (f != null ? f.hashCode() : 0);
+        result = 31 * result + (bd != null ? bd.hashCode() : 0);
+        result = 31 * result + (bi != null ? bi.hashCode() : 0);
+        result = 31 * result + (type != null ? type.hashCode() : 0);
+        return result;
+    }
+}
diff --git a/src/main/java/io/nats/client/support/JsonValueUtils.java b/src/main/java/io/nats/client/support/JsonValueUtils.java
new file mode 100644
index 000000000..b65cbde39
--- /dev/null
+++ b/src/main/java/io/nats/client/support/JsonValueUtils.java
@@ -0,0 +1,375 @@
+// Copyright 2023 The NATS Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at:
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package io.nats.client.support;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.ZonedDateTime;
+import java.util.*;
+import java.util.function.Function;
+
+import static io.nats.client.support.JsonValue.*;
+
+/**
+ * Internal json value helpers.
+ */
+public abstract class JsonValueUtils {
+
+    private JsonValueUtils() {} /* ensures cannot be constructed */
+
+    public interface JsonValueSupplier<T> {
+        T get(JsonValue v);
+    }
+
+    public static <T> T read(JsonValue jsonValue, String key, JsonValueSupplier<T> valueSupplier) {
+        JsonValue v = jsonValue == null || jsonValue.map == null ? null : jsonValue.map.get(key);
+        return valueSupplier.get(v);
+    }
+
+    public static JsonValue readValue(JsonValue jsonValue, String key) {
+        return read(jsonValue, key, v -> v);
+    }
+
+    public static JsonValue readObject(JsonValue jsonValue, String key) {
+        return read(jsonValue, key, v -> v == null ? EMPTY_MAP : v);
+    }
+
+    public static List<JsonValue> readArray(JsonValue jsonValue, String key) {
+        return read(jsonValue, key, v -> v == null ? EMPTY_ARRAY.array : v.array);
+    }
+
+    public static Map<String, String> readStringStringMap(JsonValue jv, String key) {
+        JsonValue o = readObject(jv, key);
+        if (o.type == Type.MAP && o.map.size() > 0) {
+            Map<String, String> temp = new HashMap<>();
+            for (String k : o.map.keySet()) {
+                String value = readString(o, k);
+                if (value != null) {
+                    temp.put(k, value);
+                }
+            }
+            return temp.isEmpty() ? null : temp;
+        }
+        return null;
+    }
+
+    public static String readString(JsonValue jsonValue, String key) {
+        return read(jsonValue, key, v -> v == null ? null : v.string);
+    }
+
+    public static String readString(JsonValue jsonValue, String key, String dflt) {
+        return read(jsonValue, key, v -> v == null ? dflt : v.string);
+    }
+
+    public static ZonedDateTime readDate(JsonValue jsonValue, String key) {
+        return read(jsonValue, key,
+            v -> v == null || v.string == null ? null : DateTimeUtils.parseDateTimeThrowParseError(v.string));
+    }
+
+    public static Integer readInteger(JsonValue jsonValue, String key) {
+        return read(jsonValue, key, v -> v == null ? null : getInteger(v));
+    }
+
+    public static int readInteger(JsonValue jsonValue, String key, int dflt) {
+        return read(jsonValue, key, v -> {
+            if (v != null) {
+                Integer i = getInteger(v);
+                if (i != null) {
+                    return i;
+                }
+            }
+            return dflt;
+        });
+    }
+
+    public static Long readLong(JsonValue jsonValue, String key) {
+        return read(jsonValue, key, v -> v == null ? null : getLong(v));
+    }
+
+    public static long readLong(JsonValue jsonValue, String key, long dflt) {
+        return read(jsonValue, key, v -> {
+            if (v != null) {
+                Long l = getLong(v);
+                if (l != null) {
+                    return l;
+                }
+            }
+            return dflt;
+        });
+    }
+
+    public static boolean readBoolean(JsonValue jsonValue, String key) {
+        return readBoolean(jsonValue, key, false);
+    }
+
+    public static Boolean readBoolean(JsonValue jsonValue, String key, Boolean dflt) {
+        return read(jsonValue, key,
+            v -> v == null || v.bool == null ? dflt : v.bool);
+    }
+
+    public static Duration readNanos(JsonValue jsonValue, String key) {
+        Long l = readLong(jsonValue, key);
+        return l == null ? null : Duration.ofNanos(l);
+    }
+
+    public static Duration readNanos(JsonValue jsonValue, String key, Duration dflt) {
+        Long l = readLong(jsonValue, key);
+        return l == null ? dflt : Duration.ofNanos(l);
+    }
+
+    public static <T> List<T> listOf(JsonValue v, Function<JsonValue, T> provider) {
+        List<T> list = new ArrayList<>();
+        if (v != null && v.array != null) {
+            for (JsonValue jv : v.array) {
+                T t = provider.apply(jv);
+                if (t != null) {
+                    list.add(t);
+                }
+            }
+        }
+        return list;
+    }
+
+    public static <T> List<T> optionalListOf(JsonValue v, Function<JsonValue, T> provider) {
+        List<T> list = listOf(v, provider);
+        return list.isEmpty() ? null : list;
+    }
+
+    public static List<String> readStringList(JsonValue jsonValue, String key) {
+        return read(jsonValue, key, v -> listOf(v, jv -> jv.string));
+    }
+
+    public static List<String> readStringListIgnoreEmpty(JsonValue jsonValue, String key) {
+        return read(jsonValue, key, v -> listOf(v, jv -> {
+            if (jv.string != null) {
+                String s = jv.string.trim();
+                if (!s.isEmpty()) {
+                    return s;
+                }
+            }
+            return null;
+        }));
+    }
+
+    public static List<String> readOptionalStringList(JsonValue jsonValue, String key) {
+        return read(jsonValue, key, v -> optionalListOf(v, jv -> jv.string));
+    }
+
+    public static List<Long> readLongList(JsonValue jsonValue, String key) {
+        return read(jsonValue, key, v -> listOf(v, JsonValueUtils::getLong));
+    }
+    public static List<Duration> readNanosList(JsonValue jsonValue, String key) {
+        return readNanosList(jsonValue, key, false);
+    }
+
+    public static List<Duration> readNanosList(JsonValue jsonValue, String key, boolean nullIfEmpty) {
+        List<Duration> list = read(jsonValue, key,
+            v -> listOf(v, vv -> {
+                Long l = getLong(vv);
+                return l == null ? null : Duration.ofNanos(l);
+            })
+        );
+        return list.isEmpty() && nullIfEmpty ? null : list;
+    }
+
+    public static byte[] readBytes(JsonValue jsonValue, String key) {
+        String s = readString(jsonValue, key);
+        return s == null ? null : s.getBytes(StandardCharsets.US_ASCII);
+    }
+
+    public static byte[] readBase64(JsonValue jsonValue, String key) {
+        String b64 = readString(jsonValue, key);
+        return b64 == null ? null : Base64.getDecoder().decode(b64);
+    }
+
+    public static Integer getInteger(JsonValue v) {
+        if (v.i != null) {
+            return v.i;
+        }
+        // just in case the number was stored as a long, which is unlikely, but I want to handle it
+        if (v.l != null && v.l <= Integer.MAX_VALUE && v.l >= Integer.MIN_VALUE) {
+            return v.l.intValue();
+        }
+        return null;
+    }
+
+    public static Long getLong(JsonValue v) {
+        return v.l != null ? v.l : (v.i != null ? (long)v.i : null);
+    }
+
+    public static long getLong(JsonValue v, long dflt) {
+        return v.l != null ? v.l : (v.i != null ? (long)v.i : dflt);
+    }
+
+    public static JsonValue instance(Duration d) {
+        return new JsonValue(d.toNanos());
+    }
+
+    @SuppressWarnings("rawtypes")
+    public static JsonValue instance(Collection list) {
+        JsonValue v = new JsonValue(new ArrayList<>());
+        for (Object o : list) {
+            v.array.add(toJsonValue(o));
+        }
+        return v;
+    }
+
+    @SuppressWarnings("rawtypes")
+    public static JsonValue instance(Map map) {
+        JsonValue v = new JsonValue(new HashMap<>());
+        for (Object key : map.keySet()) {
+            v.map.put(key.toString(), toJsonValue(map.get(key)));
+        }
+        return v;
+    }
+
+    public static JsonValue toJsonValue(Object o) {
+        if (o == null) {
+            return JsonValue.NULL;
+        }
+        if (o instanceof JsonValue) {
+            return (JsonValue)o;
+        }
+        if (o instanceof JsonSerializable) {
+            return ((JsonSerializable)o).toJsonValue();
+        }
+        if (o instanceof Map) {
+            //noinspection unchecked,rawtypes
+            return new JsonValue((Map)o);
+        }
+        if (o instanceof List) {
+            //noinspection unchecked,rawtypes
+            return new JsonValue((List)o);
+        }
+        if (o instanceof Set) {
+            //noinspection unchecked,rawtypes
+            return new JsonValue(new ArrayList<>((Set)o));
+        }
+        if (o instanceof String) {
+            String s = ((String)o).trim();
+            return s.length() == 0 ? new JsonValue() : new JsonValue(s);
+        }
+        if (o instanceof Boolean) {
+            return new JsonValue((Boolean)o);
+        }
+        if (o instanceof Integer) {
+            return new JsonValue((Integer)o);
+        }
+        if (o instanceof Long) {
+            return new JsonValue((Long)o);
+        }
+        if (o instanceof Double) {
+            return new JsonValue((Double)o);
+        }
+        if (o instanceof Float) {
+            return new JsonValue((Float)o);
+        }
+        if (o instanceof BigDecimal) {
+            return new JsonValue((BigDecimal)o);
+        }
+        if (o instanceof BigInteger) {
+            return new JsonValue((BigInteger)o);
+        }
+        return new JsonValue(o.toString());
+    }
+
+    public static MapBuilder mapBuilder() {
+        return new MapBuilder();
+    }
+
+    public static class MapBuilder implements JsonSerializable {
+        public JsonValue jv;
+
+        public MapBuilder() {
+            jv = new JsonValue(new HashMap<>());
+        }
+
+        public MapBuilder(JsonValue jv) {
+            this.jv = jv;
+        }
+
+        public MapBuilder put(String s, Object o) {
+            if (o != null) {
+                JsonValue vv = JsonValueUtils.toJsonValue(o);
+                if (vv.type != JsonValue.Type.NULL) {
+                    jv.map.put(s, vv);
+                    jv.mapOrder.add(s);
+                }
+            }
+            return this;
+        }
+
+        public MapBuilder put(String s, Map<String, String> stringMap) {
+            if (stringMap != null) {
+                MapBuilder mb = new MapBuilder();
+                for (String key : stringMap.keySet()) {
+                    mb.put(key, stringMap.get(key));
+                }
+                jv.map.put(s, mb.jv);
+                jv.mapOrder.add(s);
+            }
+            return this;
+        }
+
+        @Override
+        public String toJson() {
+            return jv.toJson();
+        }
+
+        @Override
+        public JsonValue toJsonValue() {
+            return jv;
+        }
+
+        @Deprecated
+        public JsonValue getJsonValue() {
+            return jv;
+        }
+    }
+
+    public static ArrayBuilder arrayBuilder() {
+        return new ArrayBuilder();
+    }
+
+    public static class ArrayBuilder implements JsonSerializable {
+        public JsonValue jv = new JsonValue(new ArrayList<>());
+        public ArrayBuilder add(Object o) {
+            if (o != null) {
+                JsonValue vv = JsonValueUtils.toJsonValue(o);
+                if (vv.type != JsonValue.Type.NULL) {
+                    jv.array.add(JsonValueUtils.toJsonValue(o));
+                }
+            }
+            return this;
+        }
+
+        @Override
+        public String toJson() {
+            return jv.toJson();
+        }
+
+        @Override
+        public JsonValue toJsonValue() {
+            return jv;
+        }
+
+        @Deprecated
+        public JsonValue getJsonValue() {
+            return jv;
+        }
+    }
+}
+
diff --git a/src/main/java/io/nats/client/support/JwtUtils.java b/src/main/java/io/nats/client/support/JwtUtils.java
index 8e318f278..aa324b1e3 100644
--- a/src/main/java/io/nats/client/support/JwtUtils.java
+++ b/src/main/java/io/nats/client/support/JwtUtils.java
@@ -16,22 +16,28 @@
 import io.nats.client.NKey;
 
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
 import java.time.Duration;
 import java.util.List;
 
-import static io.nats.client.support.JsonWriteUtils.beginJson;
-import static io.nats.client.support.JsonWriteUtils.endJson;
+import static io.nats.client.support.Encoding.*;
+import static io.nats.client.support.JsonUtils.beginJson;
+import static io.nats.client.support.JsonUtils.endJson;
 
 /**
  * Implements <a href="https://github.com/nats-io/nats-architecture-and-design/blob/main/adr/ADR-14.md">ADR-14</a>
- * @deprecated This class has been extracted to the io.nats.jwt.JwtUtils class in the <a href="https://github.com/nats-io/jwt.java">jwt.java</a> library.
  */
-@Deprecated
 public abstract class JwtUtils {
 
     private JwtUtils() {} /* ensures cannot be constructed */
 
+    private static final String ENCODED_CLAIM_HEADER =
+            toBase64Url("{\"typ\":\"JWT\", \"alg\":\"ed25519-nkey\"}");
+
+    private static final long NO_LIMIT = -1;
+
     /**
      * Format string with `%s` placeholder for the JWT token followed
      * by the user NKey seed. This can be directly used as such:
@@ -40,17 +46,27 @@ private JwtUtils() {} /* ensures cannot be constructed */
      * NKey userKey = NKey.createUser(new SecureRandom());
      * NKey signingKey = loadFromSecretStore();
      * String jwt = issueUserJWT(signingKey, accountId, new String(userKey.getPublicKey()));
-     * String.format(NATS_USER_JWT_FORMAT, jwt, new String(userKey.getSeed()));
+     * String.format(JwtUtils.NATS_USER_JWT_FORMAT, jwt, new String(userKey.getSeed()));
      * </pre>
      */
-    @Deprecated
-    public static final String NATS_USER_JWT_FORMAT = io.nats.jwt.JwtUtils.NATS_USER_JWT_FORMAT;
+    public static final String NATS_USER_JWT_FORMAT = "-----BEGIN NATS USER JWT-----\n" +
+            "%s\n" +
+            "------END NATS USER JWT------\n" +
+            "\n" +
+            "************************* IMPORTANT *************************\n" +
+            "NKEY Seed printed below can be used to sign and prove identity.\n" +
+            "NKEYs are sensitive and should be treated as secrets.\n" +
+            "\n" +
+            "-----BEGIN USER NKEY SEED-----\n" +
+            "%s\n" +
+            "------END USER NKEY SEED------\n" +
+            "\n" +
+            "*************************************************************\n";
 
     /**
      * Get the current time in seconds since epoch. Used for issue time.
      * @return the time
      */
-    @Deprecated
     public static long currentTimeSeconds() {
         return System.currentTimeMillis() / 1000;
     }
@@ -66,9 +82,8 @@ public static long currentTimeSeconds() {
      * @throws IOException if signingKey sign method throws this exception.
      * @return a JWT
      */
-    @Deprecated
     public static String issueUserJWT(NKey signingKey, String accountId, String publicUserKey) throws GeneralSecurityException, IOException {
-        return io.nats.jwt.JwtUtils.issueUserJWT(signingKey, publicUserKey, null, null, io.nats.jwt.JwtUtils.currentTimeSeconds(), null, new io.nats.jwt.UserClaim(accountId));
+        return issueUserJWT(signingKey, publicUserKey, null, null, currentTimeSeconds(), null, new UserClaim(accountId));
     }
 
     /**
@@ -83,9 +98,8 @@ public static String issueUserJWT(NKey signingKey, String accountId, String publ
      * @throws IOException if signingKey sign method throws this exception.
      * @return a JWT
      */
-    @Deprecated
     public static String issueUserJWT(NKey signingKey, String accountId, String publicUserKey, String name) throws GeneralSecurityException, IOException {
-        return io.nats.jwt.JwtUtils.issueUserJWT(signingKey, publicUserKey, name, null, io.nats.jwt.JwtUtils.currentTimeSeconds(), null, new io.nats.jwt.UserClaim(accountId));
+        return issueUserJWT(signingKey, publicUserKey, name, null, currentTimeSeconds(), null, new UserClaim(accountId));
     }
 
     /**
@@ -102,9 +116,8 @@ public static String issueUserJWT(NKey signingKey, String accountId, String publ
      * @throws IOException if signingKey sign method throws this exception.
      * @return a JWT
      */
-    @Deprecated
     public static String issueUserJWT(NKey signingKey, String accountId, String publicUserKey, String name, Duration expiration, String... tags) throws GeneralSecurityException, IOException {
-        return io.nats.jwt.JwtUtils.issueUserJWT(signingKey, accountId, publicUserKey, name, expiration, tags, null, null);
+        return issueUserJWT(signingKey, publicUserKey, name, expiration, currentTimeSeconds(), null, new UserClaim(accountId).tags(tags));
     }
 
     /**
@@ -122,30 +135,12 @@ public static String issueUserJWT(NKey signingKey, String accountId, String publ
      * @throws IOException if signingKey sign method throws this exception.
      * @return a JWT
      */
-    @Deprecated
     public static String issueUserJWT(NKey signingKey, String accountId, String publicUserKey, String name, Duration expiration, String[] tags, long issuedAt) throws GeneralSecurityException, IOException {
-        return io.nats.jwt.JwtUtils.issueUserJWT(signingKey, accountId, publicUserKey, name, expiration, tags, issuedAt, null);
+        return issueUserJWT(signingKey, publicUserKey, name, expiration, issuedAt, null, new UserClaim(accountId).tags(tags));
     }
 
-    /**
-     * Issue a user JWT from a scoped signing key. See <a href="https://docs.nats.io/nats-tools/nsc/signing_keys">Signing Keys</a>
-     * @param signingKey a mandatory account nkey pair to sign the generated jwt.
-     * @param accountId a mandatory public account nkey. Will throw error when not set or not account nkey.
-     * @param publicUserKey a mandatory public user nkey. Will throw error when not set or not user nkey.
-     * @param name optional human-readable name. When absent, default to publicUserKey.
-     * @param expiration optional but recommended duration, when the generated jwt needs to expire. If not set, JWT will not expire.
-     * @param tags optional list of tags to be included in the JWT.
-     * @param issuedAt the current epoch seconds.
-     * @param audience the optional audience
-     * @throws IllegalArgumentException if the accountId or publicUserKey is not a valid public key of the proper type
-     * @throws NullPointerException if signingKey, accountId, or publicUserKey are null.
-     * @throws GeneralSecurityException if SHA-256 MessageDigest is missing, or if the signingKey can not be used for signing.
-     * @throws IOException if signingKey sign method throws this exception.
-     * @return a JWT
-     */
-    @Deprecated
     public static String issueUserJWT(NKey signingKey, String accountId, String publicUserKey, String name, Duration expiration, String[] tags, long issuedAt, String audience) throws GeneralSecurityException, IOException {
-        return io.nats.jwt.JwtUtils.issueUserJWT(signingKey, accountId, publicUserKey, name, expiration, tags, issuedAt, audience);
+        return issueUserJWT(signingKey, publicUserKey, name, expiration, issuedAt, audience, new UserClaim(accountId).tags(tags));
     }
 
     /**
@@ -162,7 +157,6 @@ public static String issueUserJWT(NKey signingKey, String accountId, String publ
      * @throws IOException if signingKey sign method throws this exception.
      * @return a JWT
      */
-    @Deprecated
     public static String issueUserJWT(NKey signingKey, String publicUserKey, String name, Duration expiration, long issuedAt, UserClaim nats) throws GeneralSecurityException, IOException {
         return issueUserJWT(signingKey, publicUserKey, name, expiration, issuedAt, null, nats);
     }
@@ -182,7 +176,6 @@ public static String issueUserJWT(NKey signingKey, String publicUserKey, String
      * @throws IOException if signingKey sign method throws this exception.
      * @return a JWT
      */
-    @Deprecated
     public static String issueUserJWT(NKey signingKey, String publicUserKey, String name, Duration expiration, long issuedAt, String audience, UserClaim nats) throws GeneralSecurityException, IOException {
         // Validate the signingKey:
         if (signingKey.getType() != NKey.Type.ACCOUNT) {
@@ -202,7 +195,7 @@ public static String issueUserJWT(NKey signingKey, String publicUserKey, String
 
         String claimName = Validator.nullOrEmpty(name) ? publicUserKey : name;
 
-        return io.nats.jwt.JwtUtils.issueJWT(signingKey, publicUserKey, claimName, expiration, issuedAt, accSigningKeyPub, audience, nats);
+        return issueJWT(signingKey, publicUserKey, claimName, expiration, issuedAt, accSigningKeyPub, audience, nats);
     }
 
     /**
@@ -218,9 +211,8 @@ public static String issueUserJWT(NKey signingKey, String publicUserKey, String
      * @throws IOException if signingKey sign method throws this exception.
      * @return a JWT
      */
-    @Deprecated
     public static String issueJWT(NKey signingKey, String publicUserKey, String name, Duration expiration, long issuedAt, String accSigningKeyPub, JsonSerializable nats) throws GeneralSecurityException, IOException {
-        return io.nats.jwt.JwtUtils.issueJWT(signingKey, publicUserKey, name, expiration, issuedAt, accSigningKeyPub, null, nats);
+        return issueJWT(signingKey, publicUserKey, name, expiration, issuedAt, accSigningKeyPub, null, nats);
     }
 
     /**
@@ -238,9 +230,35 @@ public static String issueJWT(NKey signingKey, String publicUserKey, String name
      * @throws GeneralSecurityException if SHA-256 MessageDigest is missing, or if the signingKey can not be used for signing.
      * @throws IOException              if signingKey sign method throws this exception.
      */
-    @Deprecated
     public static String issueJWT(NKey signingKey, String publicUserKey, String name, Duration expiration, long issuedAt, String accSigningKeyPub, String audience, JsonSerializable nats) throws GeneralSecurityException, IOException {
-        return io.nats.jwt.JwtUtils.issueJWT(signingKey, publicUserKey, name, expiration, issuedAt, accSigningKeyPub, audience, nats);
+        Claim claim = new Claim();
+        claim.aud = audience;
+        claim.iat = issuedAt;
+        claim.iss = accSigningKeyPub;
+        claim.name = name;
+        claim.sub = publicUserKey;
+        claim.exp = expiration;
+        claim.nats = nats;
+
+        // Issue At time is stored in unix seconds
+        String claimJson = claim.toJson();
+
+        // Compute jti, a base32 encoded sha256 hash
+        MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
+        byte[] encoded = sha256.digest(claimJson.getBytes(StandardCharsets.US_ASCII));
+
+        claim.jti = new String(base32Encode(encoded));
+        claimJson = claim.toJson();
+
+        // all three components (header/body/signature) are base64url encoded
+        String encBody = toBase64Url(claimJson);
+
+        // compute the signature off of header + body (. included on purpose)
+        byte[] sig = (ENCODED_CLAIM_HEADER + "." + encBody).getBytes(StandardCharsets.UTF_8);
+        String encSig = toBase64Url(signingKey.sign(sig));
+
+        // append signature to header and body and return it
+        return ENCODED_CLAIM_HEADER + "." + encBody + "." + encSig;
     }
 
     /**
@@ -248,12 +266,10 @@ public static String issueJWT(NKey signingKey, String publicUserKey, String name
      * @param jwt the encoded jwt
      * @return the claim body json
      */
-    @Deprecated
     public static String getClaimBody(String jwt) {
-        return io.nats.jwt.JwtUtils.getClaimBody(jwt);
+        return fromBase64Url(jwt.split("\\.")[1]);
     }
 
-    @Deprecated
     public static class UserClaim implements JsonSerializable {
         public String issuerAccount;            // User
         public String[] tags;                   // User/GenericFields
@@ -265,9 +281,9 @@ public static class UserClaim implements JsonSerializable {
         public String[] src;                    // User/UserPermissionLimits/Limits/UserLimits
         public List<TimeRange> times;           // User/UserPermissionLimits/Limits/UserLimits
         public String locale;                   // User/UserPermissionLimits/Limits/UserLimits
-        public long subs = -1;                  // User/UserPermissionLimits/Limits/NatsLimits
-        public long data = -1;                  // User/UserPermissionLimits/Limits/NatsLimits
-        public long payload = -1;               // User/UserPermissionLimits/Limits/NatsLimits
+        public long subs = NO_LIMIT;            // User/UserPermissionLimits/Limits/NatsLimits
+        public long data = NO_LIMIT;            // User/UserPermissionLimits/Limits/NatsLimits
+        public long payload = NO_LIMIT;         // User/UserPermissionLimits/Limits/NatsLimits
         public boolean bearerToken;             // User/UserPermissionLimits
         public String[] allowedConnectionTypes; // User/UserPermissionLimits
 
@@ -278,21 +294,21 @@ public UserClaim(String issuerAccount) {
         @Override
         public String toJson() {
             StringBuilder sb = beginJson();
-            JsonWriteUtils.addField(sb, "issuer_account", issuerAccount);
-            JsonWriteUtils.addStrings(sb, "tags", tags);
-            JsonWriteUtils.addField(sb, "type", type);
-            JsonWriteUtils.addField(sb, "version", version);
-            JsonWriteUtils.addField(sb, "pub", pub);
-            JsonWriteUtils.addField(sb, "sub", sub);
-            JsonWriteUtils.addField(sb, "resp", resp);
-            JsonWriteUtils.addStrings(sb, "src", src);
-            JsonWriteUtils.addJsons(sb, "times", times);
-            JsonWriteUtils.addField(sb, "times_location", locale);
-            JsonWriteUtils.addFieldWhenGteMinusOne(sb, "subs", subs);
-            JsonWriteUtils.addFieldWhenGteMinusOne(sb, "data", data);
-            JsonWriteUtils.addFieldWhenGteMinusOne(sb, "payload", payload);
-            JsonWriteUtils.addFldWhenTrue(sb, "bearer_token", bearerToken);
-            JsonWriteUtils.addStrings(sb, "allowed_connection_types", allowedConnectionTypes);
+            JsonUtils.addField(sb, "issuer_account", issuerAccount);
+            JsonUtils.addStrings(sb, "tags", tags);
+            JsonUtils.addField(sb, "type", type);
+            JsonUtils.addField(sb, "version", version);
+            JsonUtils.addField(sb, "pub", pub);
+            JsonUtils.addField(sb, "sub", sub);
+            JsonUtils.addField(sb, "resp", resp);
+            JsonUtils.addStrings(sb, "src", src);
+            JsonUtils.addJsons(sb, "times", times);
+            JsonUtils.addField(sb, "times_location", locale);
+            JsonUtils.addFieldWhenGteMinusOne(sb, "subs", subs);
+            JsonUtils.addFieldWhenGteMinusOne(sb, "data", data);
+            JsonUtils.addFieldWhenGteMinusOne(sb, "payload", payload);
+            JsonUtils.addFldWhenTrue(sb, "bearer_token", bearerToken);
+            JsonUtils.addStrings(sb, "allowed_connection_types", allowedConnectionTypes);
             return endJson(sb).toString();
         }
 
@@ -357,7 +373,6 @@ public UserClaim allowedConnectionTypes(String... allowedConnectionTypes) {
         }
     }
 
-    @Deprecated
     public static class TimeRange implements JsonSerializable {
         public String start;
         public String end;
@@ -370,13 +385,12 @@ public TimeRange(String start, String end) {
         @Override
         public String toJson() {
             StringBuilder sb = beginJson();
-            JsonWriteUtils.addField(sb, "start", start);
-            JsonWriteUtils.addField(sb, "end", end);
+            JsonUtils.addField(sb, "start", start);
+            JsonUtils.addField(sb, "end", end);
             return endJson(sb).toString();
         }
     }
 
-    @Deprecated
     public static class ResponsePermission implements JsonSerializable {
         public int maxMsgs;
         public Duration expires;
@@ -399,8 +413,8 @@ public ResponsePermission expires(long expiresMillis) {
         @Override
         public String toJson() {
             StringBuilder sb = beginJson();
-            JsonWriteUtils.addField(sb, "max", maxMsgs);
-            JsonWriteUtils.addFieldAsNanos(sb, "ttl", expires);
+            JsonUtils.addField(sb, "max", maxMsgs);
+            JsonUtils.addFieldAsNanos(sb, "ttl", expires);
             return endJson(sb).toString();
         }
     }
@@ -422,13 +436,12 @@ public Permission deny(String... deny) {
         @Override
         public String toJson() {
             StringBuilder sb = beginJson();
-            JsonWriteUtils.addStrings(sb, "allow", allow);
-            JsonWriteUtils.addStrings(sb, "deny", deny);
+            JsonUtils.addStrings(sb, "allow", allow);
+            JsonUtils.addStrings(sb, "deny", deny);
             return endJson(sb).toString();
         }
     }
 
-    @Deprecated
     static class Claim implements JsonSerializable {
         String aud;
         String jti;
@@ -442,19 +455,19 @@ static class Claim implements JsonSerializable {
         @Override
         public String toJson() {
             StringBuilder sb = beginJson();
-            JsonWriteUtils.addField(sb, "aud", aud);
-            JsonWriteUtils.addFieldEvenEmpty(sb, "jti", jti);
-            JsonWriteUtils.addField(sb, "iat", iat);
-            JsonWriteUtils.addField(sb, "iss", iss);
-            JsonWriteUtils.addField(sb, "name", name);
-            JsonWriteUtils.addField(sb, "sub", sub);
+            JsonUtils.addField(sb, "aud", aud);
+            JsonUtils.addFieldEvenEmpty(sb, "jti", jti);
+            JsonUtils.addField(sb, "iat", iat);
+            JsonUtils.addField(sb, "iss", iss);
+            JsonUtils.addField(sb, "name", name);
+            JsonUtils.addField(sb, "sub", sub);
 
             if (exp != null && !exp.isZero() && !exp.isNegative()) {
                 long seconds = exp.toMillis() / 1000;
-                JsonWriteUtils.addField(sb, "exp", iat + seconds); // relative to the iat
+                JsonUtils.addField(sb, "exp", iat + seconds); // relative to the iat
             }
 
-            JsonWriteUtils.addField(sb, "nats", nats);
+            JsonUtils.addField(sb, "nats", nats);
             return endJson(sb).toString();
         }
     }
diff --git a/src/main/java/io/nats/client/support/Validator.java b/src/main/java/io/nats/client/support/Validator.java
index c476abdf7..c36659fa7 100644
--- a/src/main/java/io/nats/client/support/Validator.java
+++ b/src/main/java/io/nats/client/support/Validator.java
@@ -17,7 +17,6 @@
 import java.time.Duration;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.function.Supplier;
 import java.util.regex.Pattern;
 
@@ -640,33 +639,4 @@ public static boolean mapsAreEquivalent(Map<String, String> m1, Map<String, Stri
         return true;
     }
 
-    public static <T> boolean listEquals(List<T> l1, List<T> l2)
-    {
-        if (l1 == null)
-        {
-            return l2 == null;
-        }
-
-        if (l2 == null)
-        {
-            return false;
-        }
-
-        return l1.equals(l2);
-    }
-
-    public static boolean mapEquals(Map<String, String> map1, Map<String, String> map2) {
-        if (map1 == null) {
-            return map2 == null;
-        }
-        if (map2 == null || map1.size() != map2.size()) {
-            return false;
-        }
-        for (String key : map1.keySet()) {
-            if (!Objects.equals(map1.get(key), map2.get(key))) {
-                return false;
-            }
-        }
-        return true;
-    }
 }
diff --git a/src/main/java/io/nats/service/Endpoint.java b/src/main/java/io/nats/service/Endpoint.java
index 17ffd79ac..ad566ee29 100644
--- a/src/main/java/io/nats/service/Endpoint.java
+++ b/src/main/java/io/nats/service/Endpoint.java
@@ -14,6 +14,7 @@
 package io.nats.service;
 
 import io.nats.client.support.JsonSerializable;
+import io.nats.client.support.JsonUtils;
 import io.nats.client.support.JsonValue;
 import io.nats.client.support.Validator;
 
@@ -22,9 +23,9 @@
 import java.util.Objects;
 
 import static io.nats.client.support.ApiConstants.*;
+import static io.nats.client.support.JsonUtils.endJson;
 import static io.nats.client.support.JsonValueUtils.readString;
 import static io.nats.client.support.JsonValueUtils.readStringStringMap;
-import static io.nats.client.support.JsonWriteUtils.*;
 import static io.nats.client.support.Validator.validateIsRestrictedTerm;
 
 /**
@@ -128,17 +129,17 @@ public Endpoint(String name, String subject, String queueGroup, Map<String, Stri
 
     @Override
     public String toJson() {
-        StringBuilder sb = beginJson();
-        addField(sb, NAME, name);
-        addField(sb, SUBJECT, subject);
-        addField(sb, QUEUE_GROUP, queueGroup);
-        addField(sb, METADATA, metadata);
+        StringBuilder sb = JsonUtils.beginJson();
+        JsonUtils.addField(sb, NAME, name);
+        JsonUtils.addField(sb, SUBJECT, subject);
+        JsonUtils.addField(sb, QUEUE_GROUP, queueGroup);
+        JsonUtils.addField(sb, METADATA, metadata);
         return endJson(sb).toString();
     }
 
     @Override
     public String toString() {
-        return toKey(getClass()) + toJson();
+        return JsonUtils.toKey(getClass()) + toJson();
     }
 
     /**
diff --git a/src/main/java/io/nats/service/EndpointStats.java b/src/main/java/io/nats/service/EndpointStats.java
index 41f513a92..7e0c095d0 100644
--- a/src/main/java/io/nats/service/EndpointStats.java
+++ b/src/main/java/io/nats/service/EndpointStats.java
@@ -14,6 +14,7 @@
 package io.nats.service;
 
 import io.nats.client.support.JsonSerializable;
+import io.nats.client.support.JsonUtils;
 import io.nats.client.support.JsonValue;
 import io.nats.client.support.JsonValueUtils;
 
@@ -22,8 +23,9 @@
 import java.util.Objects;
 
 import static io.nats.client.support.ApiConstants.*;
+import static io.nats.client.support.JsonUtils.beginJson;
+import static io.nats.client.support.JsonUtils.endJson;
 import static io.nats.client.support.JsonValueUtils.*;
-import static io.nats.client.support.JsonWriteUtils.*;
 
 /**
  * Endpoints stats contains various stats and custom data for an endpoint.
@@ -112,16 +114,16 @@ static List<EndpointStats> listOf(JsonValue vEndpointStats) {
     @Override
     public String toJson() {
         StringBuilder sb = beginJson();
-        addField(sb, NAME, name);
-        addField(sb, SUBJECT, subject);
-        addField(sb, QUEUE_GROUP, queueGroup);
-        addFieldWhenGtZero(sb, NUM_REQUESTS, numRequests);
-        addFieldWhenGtZero(sb, NUM_ERRORS, numErrors);
-        addFieldWhenGtZero(sb, PROCESSING_TIME, processingTime);
-        addFieldWhenGtZero(sb, AVERAGE_PROCESSING_TIME, averageProcessingTime);
-        addField(sb, LAST_ERROR, lastError);
-        addField(sb, DATA, data);
-        addField(sb, STARTED, started);
+        JsonUtils.addField(sb, NAME, name);
+        JsonUtils.addField(sb, SUBJECT, subject);
+        JsonUtils.addField(sb, QUEUE_GROUP, queueGroup);
+        JsonUtils.addFieldWhenGtZero(sb, NUM_REQUESTS, numRequests);
+        JsonUtils.addFieldWhenGtZero(sb, NUM_ERRORS, numErrors);
+        JsonUtils.addFieldWhenGtZero(sb, PROCESSING_TIME, processingTime);
+        JsonUtils.addFieldWhenGtZero(sb, AVERAGE_PROCESSING_TIME, averageProcessingTime);
+        JsonUtils.addField(sb, LAST_ERROR, lastError);
+        JsonUtils.addField(sb, DATA, data);
+        JsonUtils.addField(sb, STARTED, started);
         return endJson(sb).toString();
     }
 
@@ -215,7 +217,7 @@ public ZonedDateTime getStarted() {
 
     @Override
     public String toString() {
-        return toKey(getClass()) + toJson();
+        return JsonUtils.toKey(getClass()) + toJson();
     }
 
     @Override
diff --git a/src/main/java/io/nats/service/InfoResponse.java b/src/main/java/io/nats/service/InfoResponse.java
index 838e8a022..fbbbb4f80 100644
--- a/src/main/java/io/nats/service/InfoResponse.java
+++ b/src/main/java/io/nats/service/InfoResponse.java
@@ -13,16 +13,15 @@
 
 package io.nats.service;
 
+import io.nats.client.support.JsonUtils;
 import io.nats.client.support.JsonValue;
-import io.nats.client.support.Validator;
 
 import java.util.*;
 
 import static io.nats.client.support.ApiConstants.DESCRIPTION;
 import static io.nats.client.support.ApiConstants.ENDPOINTS;
+import static io.nats.client.support.JsonUtils.listEquals;
 import static io.nats.client.support.JsonValueUtils.*;
-import static io.nats.client.support.JsonWriteUtils.addField;
-import static io.nats.client.support.JsonWriteUtils.addJsons;
 
 /**
  * Info response class forms the info json payload, for example:
@@ -61,8 +60,8 @@ private InfoResponse(JsonValue jv) {
 
     @Override
     protected void subToJson(StringBuilder sb) {
-        addField(sb, DESCRIPTION, description);
-        addJsons(sb, ENDPOINTS, endpoints);
+        JsonUtils.addField(sb, DESCRIPTION, description);
+        JsonUtils.addJsons(sb, ENDPOINTS, endpoints);
     }
 
     /**
@@ -90,7 +89,7 @@ public boolean equals(Object o) {
         InfoResponse that = (InfoResponse) o;
 
         if (!Objects.equals(description, that.description)) return false;
-        return Validator.listEquals(endpoints, that.endpoints);
+        return listEquals(endpoints, that.endpoints);
     }
 
     @Override
diff --git a/src/main/java/io/nats/service/Service.java b/src/main/java/io/nats/service/Service.java
index bf9dc537a..2f3b34274 100644
--- a/src/main/java/io/nats/service/Service.java
+++ b/src/main/java/io/nats/service/Service.java
@@ -16,6 +16,7 @@
 import io.nats.client.Connection;
 import io.nats.client.Dispatcher;
 import io.nats.client.support.DateTimeUtils;
+import io.nats.client.support.JsonUtils;
 
 import java.time.Duration;
 import java.time.ZonedDateTime;
@@ -27,7 +28,7 @@
 import java.util.concurrent.TimeUnit;
 
 import static io.nats.client.support.ApiConstants.*;
-import static io.nats.client.support.JsonWriteUtils.*;
+import static io.nats.client.support.JsonUtils.endJson;
 import static io.nats.client.support.Validator.nullOrEmpty;
 
 /**
@@ -348,11 +349,11 @@ public EndpointStats getEndpointStats(String endpointName) {
 
     @Override
     public String toString() {
-        StringBuilder sb = beginJsonPrefixed("\"Service\":");
-        addField(sb, ID, infoResponse.getId());
-        addField(sb, NAME, infoResponse.getName());
-        addField(sb, VERSION, infoResponse.getVersion());
-        addField(sb, DESCRIPTION, infoResponse.getDescription());
+        StringBuilder sb = JsonUtils.beginJsonPrefixed("\"Service\":");
+        JsonUtils.addField(sb, ID, infoResponse.getId());
+        JsonUtils.addField(sb, NAME, infoResponse.getName());
+        JsonUtils.addField(sb, VERSION, infoResponse.getVersion());
+        JsonUtils.addField(sb, DESCRIPTION, infoResponse.getDescription());
         return endJson(sb).toString();
     }
 }
diff --git a/src/main/java/io/nats/service/ServiceResponse.java b/src/main/java/io/nats/service/ServiceResponse.java
index be45c9566..9e8bdba56 100644
--- a/src/main/java/io/nats/service/ServiceResponse.java
+++ b/src/main/java/io/nats/service/ServiceResponse.java
@@ -20,9 +20,9 @@
 import java.util.Objects;
 
 import static io.nats.client.support.ApiConstants.*;
+import static io.nats.client.support.JsonUtils.endJson;
 import static io.nats.client.support.JsonValueUtils.readString;
 import static io.nats.client.support.JsonValueUtils.readStringStringMap;
-import static io.nats.client.support.JsonWriteUtils.*;
 
 /**
  * Base class for service responses Info, Ping and Stats
@@ -114,20 +114,20 @@ protected void subToJson(StringBuilder sb) {}
 
     @Override
     public String toJson() {
-        StringBuilder sb = beginJson();
-        addField(sb, ID, id);
-        addField(sb, NAME, name);
-        addField(sb, VERSION, version);
+        StringBuilder sb = JsonUtils.beginJson();
+        JsonUtils.addField(sb, ID, id);
+        JsonUtils.addField(sb, NAME, name);
+        JsonUtils.addField(sb, VERSION, version);
         subToJson(sb);
-        addField(sb, TYPE, type);
-        addField(sb, METADATA, metadata);
+        JsonUtils.addField(sb, TYPE, type);
+        JsonUtils.addField(sb, METADATA, metadata);
         return endJson(sb).toString();
     }
 
 
     @Override
     public String toString() {
-        return toKey(getClass()) + toJson();
+        return JsonUtils.toKey(getClass()) + toJson();
     }
 
     @Override
@@ -141,7 +141,7 @@ public boolean equals(Object o) {
         if (!Objects.equals(name, that.name)) return false;
         if (!Objects.equals(id, that.id)) return false;
         if (!Objects.equals(version, that.version)) return false;
-        return Validator.mapEquals(metadata, that.metadata);
+        return JsonUtils.mapEquals(metadata, that.metadata);
     }
 
     @Override
diff --git a/src/main/java/io/nats/service/StatsResponse.java b/src/main/java/io/nats/service/StatsResponse.java
index 63855c706..2723d3a4a 100644
--- a/src/main/java/io/nats/service/StatsResponse.java
+++ b/src/main/java/io/nats/service/StatsResponse.java
@@ -13,6 +13,7 @@
 
 package io.nats.service;
 
+import io.nats.client.support.JsonUtils;
 import io.nats.client.support.JsonValue;
 
 import java.time.ZonedDateTime;
@@ -23,8 +24,6 @@
 import static io.nats.client.support.ApiConstants.STARTED;
 import static io.nats.client.support.JsonValueUtils.readDate;
 import static io.nats.client.support.JsonValueUtils.readValue;
-import static io.nats.client.support.JsonWriteUtils.addField;
-import static io.nats.client.support.JsonWriteUtils.addJsons;
 
 /**
  * Stats response class forms the stats json payload, for example:
@@ -88,8 +87,8 @@ private StatsResponse(JsonValue jv) {
 
     @Override
     protected void subToJson(StringBuilder sb) {
-        addJsons(sb, ENDPOINTS, endpointStatsList);
-        addField(sb, STARTED, started);
+        JsonUtils.addJsons(sb, ENDPOINTS, endpointStatsList);
+        JsonUtils.addField(sb, STARTED, started);
     }
 
     /**
diff --git a/src/test/java/io/nats/client/AuthTests.java b/src/test/java/io/nats/client/AuthTests.java
index e6209636d..6be7fb738 100644
--- a/src/test/java/io/nats/client/AuthTests.java
+++ b/src/test/java/io/nats/client/AuthTests.java
@@ -16,6 +16,7 @@
 import io.nats.client.Connection.Status;
 import io.nats.client.ConnectionListener.Events;
 import io.nats.client.impl.TestHandler;
+import io.nats.client.support.JwtUtils;
 import io.nats.client.utils.ResourceUtils;
 import io.nats.client.utils.TestBase;
 import org.junit.jupiter.api.Test;
@@ -31,8 +32,6 @@
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 
-import static io.nats.jwt.JwtUtils.NATS_USER_JWT_FORMAT;
-import static io.nats.jwt.JwtUtils.issueUserJWT;
 import static org.junit.jupiter.api.Assertions.*;
 
 public class AuthTests extends TestBase {
@@ -714,9 +713,9 @@ else if (error.equalsIgnoreCase("authorization violation")) {
         long expires = 2500;
         long wait = 5000;
         Duration expiration = Duration.ofMillis(expires);
-        String jwt = issueUserJWT(nKeyAccount, accountId, publicUserKey, "jnatsTestUser", expiration);
+        String jwt = JwtUtils.issueUserJWT(nKeyAccount, accountId, publicUserKey, "jnatsTestUser", expiration);
 
-        String creds = String.format(NATS_USER_JWT_FORMAT, jwt, new String(nKeyUser.getSeed()));
+        String creds = String.format(JwtUtils.NATS_USER_JWT_FORMAT, jwt, new String(nKeyUser.getSeed()));
         String credsFile = ResourceUtils.createTempFile("nats_java_test", ".creds", creds.split("\\Q\\n\\E"));
 
         try (NatsTestServer ts = new NatsTestServer("src/test/resources/operatorJnatsTest.conf", false)) {
diff --git a/src/test/java/io/nats/client/NKeyTests.java b/src/test/java/io/nats/client/NKeyTests.java
new file mode 100644
index 000000000..0749b15e9
--- /dev/null
+++ b/src/test/java/io/nats/client/NKeyTests.java
@@ -0,0 +1,600 @@
+// Copyright 2018 The NATS Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at:
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package io.nats.client;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.List;
+
+import static io.nats.client.NKey.removePaddingAndClear;
+import static io.nats.client.support.Encoding.base32Decode;
+import static io.nats.client.support.Encoding.base32Encode;
+import static io.nats.client.utils.ResourceUtils.dataAsLines;
+import static org.junit.jupiter.api.Assertions.*;
+
+public class NKeyTests {
+    private static final int ED25519_SIGNATURE_SIZE = 64;
+
+    @Test
+    public void testCRC16() {
+        // Example inputs and outputs from around the web
+        byte[][] inputs = {
+                {},
+                "abc".getBytes(StandardCharsets.US_ASCII),
+                "ABC".getBytes(StandardCharsets.US_ASCII),
+                "This is a string".getBytes(StandardCharsets.US_ASCII),
+                "123456789".getBytes(StandardCharsets.US_ASCII),
+                "abcdefghijklmnopqrstuvwxyz0123456789".getBytes(StandardCharsets.US_ASCII),
+                {(byte) 0x7F},
+                {(byte) 0x80},
+                {(byte) 0xFF},
+                {0x0, 0x1, 0x7D, 0x7E, (byte) 0x7F, (byte) 0x80, (byte) 0xFE, (byte) 0xFF}
+        };
+
+        int[] expected = {
+                0x0, // ""
+                0x9DD6, // "abc"
+                0x3994, // "ABC"
+                0x21E3, // "This is a string"
+                0x31C3, // "123456789"
+                0xCBDE, // "abcdefghijklmnopqrstuvwxyz0123456789"
+                0x8F78, // 0x7F
+                0x9188, // 0x80
+                0x1EF0, // 0xFF
+                0xE26F, // {0x0,0x1,0x7D,0x7E, 0x7F, 0x80, 0xFE, 0xFF}
+        };
+
+        for (int i = 0; i < inputs.length; i++) {
+            byte[] input = inputs[i];
+            int crc = expected[i];
+            int actual = NKey.crc16(input);
+            assertEquals(crc, actual, String.format("CRC for \"%s\", should be 0x%08X but was 0x%08X", Arrays.toString(input), crc, actual));
+        }
+    }
+
+    @Test
+    public void testBase32() {
+        List<String> inputs = dataAsLines("utf8-test-strings.txt");
+
+        for (String expected : inputs) {
+            byte[] bytes = expected.getBytes(StandardCharsets.UTF_8);
+            char[] encoded = base32Encode(bytes);
+            byte[] decoded = base32Decode(encoded);
+            String test = new String(decoded, StandardCharsets.UTF_8);
+            assertEquals(test, expected);
+        }
+
+        // bad input for coverage
+        byte[] decoded = base32Decode("/".toCharArray());
+        assertEquals(0, decoded.length);
+        decoded = base32Decode(Character.toChars(512));
+        assertEquals(0, decoded.length);
+    }
+
+    @Test
+    public void testEncodeDecodeSeed() throws Exception {
+        byte[] bytes = new byte[64];
+        SecureRandom random = new SecureRandom();
+        random.nextBytes(bytes);
+
+        char[] encoded = NKey.encodeSeed(NKey.Type.ACCOUNT, bytes);
+        DecodedSeed decoded = NKey.decodeSeed(encoded);
+
+        assertEquals(NKey.Type.fromPrefix(decoded.prefix), NKey.Type.ACCOUNT);
+        assertTrue(Arrays.equals(bytes, decoded.bytes));
+    }
+
+    @Test
+    public void testEncodeDecode() throws Exception {
+        byte[] bytes = new byte[32];
+        SecureRandom random = new SecureRandom();
+        random.nextBytes(bytes);
+
+        char[] encoded = NKey.encode(NKey.Type.ACCOUNT, bytes);
+        byte[] decoded = NKey.decode(NKey.Type.ACCOUNT, encoded, false);
+        assertTrue(Arrays.equals(bytes, decoded));
+
+        encoded = NKey.encode(NKey.Type.USER, bytes);
+        decoded = NKey.decode(NKey.Type.USER, encoded, false);
+        assertTrue(Arrays.equals(bytes, decoded));
+
+        encoded = NKey.encode(NKey.Type.SERVER, bytes);
+        decoded = NKey.decode(NKey.Type.SERVER, encoded, false);
+        assertTrue(Arrays.equals(bytes, decoded));
+
+        encoded = NKey.encode(NKey.Type.CLUSTER, bytes);
+        decoded = NKey.decode(NKey.Type.CLUSTER, encoded, false);
+        assertTrue(Arrays.equals(bytes, decoded));
+    }
+
+    @Test
+    public void testDecodeWrongType() {
+        assertThrows(IllegalArgumentException.class, () -> {
+            byte[] bytes = new byte[32];
+            SecureRandom random = new SecureRandom();
+            random.nextBytes(bytes);
+
+            char[] encoded = NKey.encode(NKey.Type.ACCOUNT, bytes);
+            NKey.decode(NKey.Type.USER, encoded, false);
+        });
+    }
+
+    @Test
+    public void testEncodeSeedSize() {
+        assertThrows(IllegalArgumentException.class, () -> {
+            byte[] bytes = new byte[48];
+            SecureRandom random = new SecureRandom();
+            random.nextBytes(bytes);
+
+            NKey.encodeSeed(NKey.Type.ACCOUNT, bytes);
+        });
+    }
+
+    @Test
+    public void testDecodeSize() {
+        assertThrows(IllegalArgumentException.class, () -> NKey.decode(NKey.Type.ACCOUNT, "".toCharArray(), false));
+    }
+
+    @Test
+    public void testBadCRC() throws Exception {
+        for (int i = 0; i < 10000; i++) {
+            try {
+                byte[] bytes = new byte[32];
+                SecureRandom random = new SecureRandom();
+                random.nextBytes(bytes);
+
+                char[] encoded = NKey.encode(NKey.Type.ACCOUNT, bytes);
+
+                StringBuilder builder = new StringBuilder();
+
+                for (int j = 0; j < encoded.length; j++) {
+                    if (j == 6) {
+                        char c = encoded[j];
+                        if (c == 'x' || c == 'X') {
+                            builder.append('Z');
+                        } else {
+                            builder.append('X');
+                        }
+                    } else {
+                        builder.append(encoded[j]);
+                    }
+                }
+
+                NKey.decode(NKey.Type.ACCOUNT, builder.toString().toCharArray(), false);
+                fail();
+            } catch (IllegalArgumentException e) {
+                //expected
+            }
+        }
+    }
+
+    @Test
+    public void testAccount() throws Exception {
+        NKey theKey = NKey.createAccount(null);
+        assertNotNull(theKey);
+
+        char[] seed = theKey.getSeed();
+        NKey.decodeSeed(seed); // throws if there is an issue
+
+        assertEquals(NKey.fromSeed(theKey.getSeed()), NKey.fromSeed(theKey.getSeed()));
+
+        char[] publicKey = theKey.getPublicKey();
+        assertEquals(publicKey[0], 'A');
+
+        char[] privateKey = theKey.getPrivateKey();
+        assertEquals(privateKey[0], 'P');
+
+        byte[] data = "Synadia".getBytes(StandardCharsets.UTF_8);
+        byte[] sig = theKey.sign(data);
+
+        assertEquals(sig.length, ED25519_SIGNATURE_SIZE);
+
+        assertTrue(theKey.verify(data, sig));
+
+        NKey otherKey = NKey.createAccount(null);
+        assertFalse(otherKey.verify(data, sig));
+        assertNotEquals(otherKey, theKey);
+
+        assertTrue(NKey.isValidPublicAccountKey(publicKey));
+        assertFalse(NKey.isValidPublicClusterKey(publicKey));
+        assertFalse(NKey.isValidPublicOperatorKey(publicKey));
+        assertFalse(NKey.isValidPublicUserKey(publicKey));
+        assertFalse(NKey.isValidPublicServerKey(publicKey));
+    }
+
+    @Test
+    public void testUser() throws Exception {
+        NKey theKey = NKey.createUser(null);
+        assertNotNull(theKey);
+
+        char[] seed = theKey.getSeed();
+        NKey.decodeSeed(seed); // throws if there is an issue
+
+        assertEquals(NKey.fromSeed(theKey.getSeed()), NKey.fromSeed(theKey.getSeed()));
+
+        char[] publicKey = theKey.getPublicKey();
+        assertEquals(publicKey[0], 'U');
+
+        char[] privateKey = theKey.getPrivateKey();
+        assertEquals(privateKey[0], 'P');
+
+        byte[] data = "Mister Zero".getBytes(StandardCharsets.UTF_8);
+        byte[] sig = theKey.sign(data);
+
+        assertEquals(sig.length, ED25519_SIGNATURE_SIZE);
+
+        assertTrue(theKey.verify(data, sig));
+
+        NKey otherKey = NKey.createUser(null);
+        assertFalse(otherKey.verify(data, sig));
+        assertNotEquals(otherKey, theKey);
+
+        assertTrue(NKey.isValidPublicUserKey(publicKey));
+        assertFalse(NKey.isValidPublicAccountKey(publicKey));
+        assertFalse(NKey.isValidPublicClusterKey(publicKey));
+        assertFalse(NKey.isValidPublicOperatorKey(publicKey));
+        assertFalse(NKey.isValidPublicServerKey(publicKey));
+    }
+
+    @Test
+    public void testCluster() throws Exception {
+        NKey theKey = NKey.createCluster(null);
+        assertNotNull(theKey);
+
+        char[] seed = theKey.getSeed();
+        NKey.decodeSeed(seed); // throws if there is an issue
+
+        assertEquals(NKey.fromSeed(theKey.getSeed()), NKey.fromSeed(theKey.getSeed()));
+
+        char[] publicKey = theKey.getPublicKey();
+        assertEquals(publicKey[0], 'C');
+
+        char[] privateKey = theKey.getPrivateKey();
+        assertEquals(privateKey[0], 'P');
+
+        byte[] data = "Connect Everything".getBytes(StandardCharsets.UTF_8);
+        byte[] sig = theKey.sign(data);
+
+        assertEquals(sig.length, ED25519_SIGNATURE_SIZE);
+
+        assertTrue(theKey.verify(data, sig));
+
+        NKey otherKey = NKey.createCluster(null);
+        assertFalse(otherKey.verify(data, sig));
+        assertNotEquals(otherKey, theKey);
+
+        assertTrue(NKey.isValidPublicClusterKey(publicKey));
+        assertFalse(NKey.isValidPublicAccountKey(publicKey));
+        assertFalse(NKey.isValidPublicOperatorKey(publicKey));
+        assertFalse(NKey.isValidPublicUserKey(publicKey));
+        assertFalse(NKey.isValidPublicServerKey(publicKey));
+    }
+
+    @Test
+    public void testOperator() throws Exception {
+        NKey theKey = NKey.createOperator(null);
+        assertNotNull(theKey);
+
+        char[] seed = theKey.getSeed();
+        NKey.decodeSeed(seed); // throws if there is an issue
+
+        assertEquals(NKey.fromSeed(theKey.getSeed()), NKey.fromSeed(theKey.getSeed()));
+
+        char[] publicKey = theKey.getPublicKey();
+        assertEquals(publicKey[0], 'O');
+
+        char[] privateKey = theKey.getPrivateKey();
+        assertEquals(privateKey[0], 'P');
+
+        byte[] data = "Connect Everything".getBytes(StandardCharsets.UTF_8);
+        byte[] sig = theKey.sign(data);
+
+        assertEquals(sig.length, ED25519_SIGNATURE_SIZE);
+
+        assertTrue(theKey.verify(data, sig));
+
+        NKey otherKey = NKey.createOperator(null);
+        assertFalse(otherKey.verify(data, sig));
+        assertNotEquals(otherKey, theKey);
+
+        assertTrue(NKey.isValidPublicOperatorKey(publicKey));
+        assertFalse(NKey.isValidPublicAccountKey(publicKey));
+        assertFalse(NKey.isValidPublicClusterKey(publicKey));
+        assertFalse(NKey.isValidPublicUserKey(publicKey));
+        assertFalse(NKey.isValidPublicServerKey(publicKey));
+    }
+
+    @Test
+    public void testServer() throws Exception {
+        NKey theKey = NKey.createServer(null);
+        assertNotNull(theKey);
+
+        char[] seed = theKey.getSeed();
+        NKey.decodeSeed(seed); // throws if there is an issue
+
+        assertEquals(NKey.fromSeed(theKey.getSeed()), NKey.fromSeed(theKey.getSeed()));
+
+        char[] publicKey = theKey.getPublicKey();
+        assertEquals(publicKey[0], 'N');
+
+        char[] privateKey = theKey.getPrivateKey();
+        assertEquals(privateKey[0], 'P');
+
+        byte[] data = "Polaris and Pluto".getBytes(StandardCharsets.UTF_8);
+        byte[] sig = theKey.sign(data);
+
+        assertEquals(sig.length, ED25519_SIGNATURE_SIZE);
+
+        assertTrue(theKey.verify(data, sig));
+
+        NKey otherKey = NKey.createServer(null);
+        assertFalse(otherKey.verify(data, sig));
+        assertNotEquals(otherKey, theKey);
+
+        assertTrue(NKey.isValidPublicServerKey(publicKey));
+        assertFalse(NKey.isValidPublicAccountKey(publicKey));
+        assertFalse(NKey.isValidPublicClusterKey(publicKey));
+        assertFalse(NKey.isValidPublicOperatorKey(publicKey));
+        assertFalse(NKey.isValidPublicUserKey(publicKey));
+    }
+
+    @Test
+    public void testPublicOnly() throws Exception {
+        NKey theKey = NKey.createUser(null);
+        assertNotNull(theKey);
+
+        char[] publicKey = theKey.getPublicKey();
+
+        assertEquals(NKey.fromPublicKey(publicKey), NKey.fromPublicKey(publicKey));
+        assertEquals(NKey.fromPublicKey(publicKey).hashCode(), NKey.fromPublicKey(publicKey).hashCode());
+
+        NKey pubOnly = NKey.fromPublicKey(publicKey);
+
+        assertEquals(pubOnly, pubOnly); // for coverage
+
+        byte[] data = "Public and Private".getBytes(StandardCharsets.UTF_8);
+        byte[] sig = theKey.sign(data);
+
+        assertTrue(pubOnly.verify(data, sig));
+
+        NKey otherKey = NKey.createServer(null);
+        assertFalse(otherKey.verify(data, sig));
+        assertNotEquals(otherKey, theKey);
+        assertNotEquals(otherKey, pubOnly);
+
+        assertNotEquals(pubOnly.getPublicKey()[0], '\0');
+        pubOnly.clear();
+        assertEquals(pubOnly.getPublicKey()[0], '\0');
+    }
+
+    @Test
+    public void testPublicOnlyCantSign() {
+        assertThrows(IllegalStateException.class, () -> {
+            NKey theKey = NKey.createUser(null);
+            NKey pubOnly = NKey.fromPublicKey(theKey.getPublicKey());
+
+            byte[] data = "Public and Private".getBytes(StandardCharsets.UTF_8);
+            pubOnly.sign(data);
+        });
+    }
+
+    @Test
+    public void testPublicOnlyCantProvideSeed() {
+        assertThrows(IllegalStateException.class, () -> {
+            NKey theKey = NKey.createUser(null);
+            NKey pubOnly = NKey.fromPublicKey(theKey.getPublicKey());
+            pubOnly.getSeed();
+        });
+    }
+
+    @Test
+    public void testPublicOnlyCantProvidePrivate() {
+        assertThrows(IllegalStateException.class, () -> {
+            NKey theKey = NKey.createUser(null);
+            NKey pubOnly = NKey.fromPublicKey(theKey.getPublicKey());
+            pubOnly.getPrivateKey();
+        });
+    }
+
+    @Test
+    public void testPublicFromSeedShouldFail() {
+        assertThrows(IllegalArgumentException.class, () -> {
+            NKey theKey = NKey.createUser(null);
+            NKey.fromPublicKey(theKey.getSeed());
+        });
+    }
+
+    @Test
+    public void testSeedFromPublicShouldFail() {
+        assertThrows(IllegalArgumentException.class, () -> {
+            NKey theKey = NKey.createUser(null);
+            NKey.fromSeed(theKey.getPublicKey());
+        });
+    }
+
+    @Test
+    public void testFromSeed() throws Exception {
+        NKey theKey = NKey.createAccount(null);
+        assertNotNull(theKey);
+
+        char[] seed = theKey.getSeed();
+        assertEquals(NKey.fromSeed(seed), NKey.fromSeed(seed));
+        assertEquals(NKey.fromSeed(seed).hashCode(), NKey.fromSeed(seed).hashCode());
+        assertTrue(Arrays.equals(NKey.fromSeed(seed).getPublicKey(), NKey.fromSeed(seed).getPublicKey()));
+        assertTrue(Arrays.equals(NKey.fromSeed(seed).getPrivateKey(), NKey.fromSeed(seed).getPrivateKey()));
+
+        assertTrue(seed[0] == 'S' && seed[1] == 'A');
+
+        NKey fromSeed = NKey.fromSeed(seed);
+
+        byte[] data = "Seeds into trees".getBytes(StandardCharsets.UTF_8);
+        byte[] sig = theKey.sign(data);
+
+        assertTrue(fromSeed.verify(data, sig));
+
+        NKey otherKey = NKey.createServer(null);
+        assertFalse(otherKey.verify(data, sig));
+        assertNotEquals(otherKey, theKey);
+        assertNotEquals(otherKey, fromSeed);
+    }
+
+    @Test
+    public void testFromBadSeed() {
+        assertThrows(IllegalArgumentException.class, () -> NKey.fromSeed("BadSeed".toCharArray()));
+    }
+
+    @Test
+    public void testFromBadPublicKey() {
+        assertThrows(IllegalArgumentException.class, () -> NKey.fromPublicKey("BadSeed".toCharArray()));
+    }
+
+    @Test
+    public void testBigSignVerify() throws Exception {
+        NKey theKey = NKey.createAccount(null);
+        assertNotNull(theKey);
+
+        byte[] data = Files.readAllBytes(Paths.get("src/test/resources/keystore.jks"));
+        byte[] sig = theKey.sign(data);
+
+        assertEquals(sig.length, ED25519_SIGNATURE_SIZE);
+        assertTrue(theKey.verify(data, sig));
+
+        char[] publicKey = theKey.getPublicKey();
+        assertTrue(NKey.fromPublicKey(publicKey).verify(data, sig));
+
+        NKey otherKey = NKey.createUser(null);
+        byte[] sig2 = otherKey.sign(data);
+
+        assertFalse(otherKey.verify(data, sig));
+        assertFalse(Arrays.equals(sig2, sig));
+        assertTrue(otherKey.verify(data, sig2));
+    }
+
+    /*
+        Compatibility/Interop data created from the following go code:
+    	user, _ := nkeys.CreateUser(nil)
+        seed, _ := user.Seed()
+        publicKey, _ := user.PublicKey()
+        privateKey, _ := user.PrivateKey()
+
+        data := []byte("Hello World")
+        sig, _ := user.Sign(data)
+        encSig := base64.URLEncoding.EncodeToString(sig)
+
+        fmt.Printf("Seed: %q\n", seed)
+        fmt.Printf("Public: %q\n", publicKey)
+        fmt.Printf("Private: %q\n", privateKey)
+
+        fmt.Printf("Data: %q\n", data)
+        fmt.Printf("Signature: %q\n", encSig)
+     */
+    @Test
+    public void testInterop() throws Exception {
+        char[] seed = "SUAOXETHU4AZD2424VFDTDJ4TOEUSGZIXMRS6F3MSCMHUUORYHNEVM6ADE".toCharArray();
+        char[] publicKey = "UB2YRJYJEFC5GZA5I47TCYYBIXQRAUA6B3MC4SR2WTXNUX6MTYM6BTBP".toCharArray();
+        char[] privateKey = "PDVZEZ5HAGI6XGXFJI4Y2PE3RFERWKF3EMXRO3EQTB5FDUOB3JFLG5MIU4ESCROTMQOUOPZRMMAULYIQKAPA5WBOJI5LJ3W2L7GJ4GPAINHQ".toCharArray();
+        String encodedSig = "dMSvD2P1Fm6knQGdMwz5h41aPYIOiPqwR-a3b7UNVJr4FcEfFoAIRbm_gtvLGIpplHTc7sZnSMeaS3Ogm1W_CA";
+        String nonce = "UkY0TGZNbEVianJZY09F";
+        String nonceEncodedSig = "ZNNvu8FDPhpVlyIqjfZGnLCmoAUQggdfdvhGtWLy29AM9TSa6_j15J2iph37j6_FvkGdd1v3crDANwHCqJuQCw";
+        byte[] data = "Hello World".getBytes(StandardCharsets.UTF_8);
+        NKey fromSeed = NKey.fromSeed(seed);
+        NKey fromPublicKey = NKey.fromPublicKey(publicKey);
+
+        assertEquals(fromSeed.getType(), NKey.Type.USER);
+
+        byte[] nonceData = Base64.getUrlDecoder().decode(nonce);
+        byte[] nonceSig = Base64.getUrlDecoder().decode(nonceEncodedSig);
+        byte[] seedNonceSig = fromSeed.sign(nonceData);
+        String encodedSeedNonceSig = Base64.getUrlEncoder().withoutPadding().encodeToString(seedNonceSig);
+
+        assertTrue(Arrays.equals(seedNonceSig, nonceSig));
+        assertEquals(nonceEncodedSig, encodedSeedNonceSig);
+
+        assertTrue(fromSeed.verify(nonceData, nonceSig));
+        assertTrue(fromPublicKey.verify(nonceData, nonceSig));
+        assertTrue(fromSeed.verify(nonceData, seedNonceSig));
+        assertTrue(fromPublicKey.verify(nonceData, seedNonceSig));
+
+        byte[] seedSig = fromSeed.sign(data);
+        byte[] sig = Base64.getUrlDecoder().decode(encodedSig);
+        String encodedSeedSig = Base64.getUrlEncoder().withoutPadding().encodeToString(seedSig);
+
+        assertTrue(Arrays.equals(seedSig, sig));
+        assertEquals(encodedSig, encodedSeedSig);
+
+        assertTrue(fromSeed.verify(data, sig));
+        assertTrue(fromPublicKey.verify(data, sig));
+        assertTrue(fromSeed.verify(data, seedSig));
+        assertTrue(fromPublicKey.verify(data, seedSig));
+
+        // Make sure generation is the same
+        assertTrue(Arrays.equals(fromSeed.getSeed(), seed));
+        assertTrue(Arrays.equals(fromSeed.getPublicKey(), publicKey));
+        assertTrue(Arrays.equals(fromSeed.getPrivateKey(), privateKey));
+
+        DecodedSeed decoded = NKey.decodeSeed(seed);
+        char[] encodedSeed = NKey.encodeSeed(NKey.Type.fromPrefix(decoded.prefix), decoded.bytes);
+        assertTrue(Arrays.equals(encodedSeed, seed));
+    }
+
+    @Test
+    public void testTypeEnum() {
+        assertEquals(NKey.Type.USER, NKey.Type.fromPrefix(NKey.PREFIX_BYTE_USER));
+        assertEquals(NKey.Type.ACCOUNT, NKey.Type.fromPrefix(NKey.PREFIX_BYTE_ACCOUNT));
+        assertEquals(NKey.Type.SERVER, NKey.Type.fromPrefix(NKey.PREFIX_BYTE_SERVER));
+        assertEquals(NKey.Type.OPERATOR, NKey.Type.fromPrefix(NKey.PREFIX_BYTE_OPERATOR));
+        assertEquals(NKey.Type.CLUSTER, NKey.Type.fromPrefix(NKey.PREFIX_BYTE_CLUSTER));
+        assertEquals(NKey.Type.ACCOUNT, NKey.Type.fromPrefix(NKey.PREFIX_BYTE_PRIVATE));
+        assertThrows(IllegalArgumentException.class, () -> { NKey.Type ignored = NKey.Type.fromPrefix(9999); });
+    }
+
+    @Test
+    public void testRemovePaddingAndClear() {
+        char[] withPad = "!".toCharArray();
+        char[] removed = removePaddingAndClear(withPad);
+        assertEquals(withPad.length, removed.length);
+        assertEquals('!', removed[0]);
+
+        withPad = "a=".toCharArray();
+        removed = removePaddingAndClear(withPad);
+        assertEquals(1, removed.length);
+        assertEquals('a', removed[0]);
+    }
+
+    @Test
+    public void testEquals() throws Exception {
+        NKey key = NKey.createServer(null);
+        assertEquals(key, key);
+        assertEquals(key, NKey.fromSeed(key.getSeed()));
+        assertNotEquals(key, new Object());
+        assertNotEquals(key, NKey.createServer(null));
+        assertNotEquals(key, NKey.createAccount(null));
+    }
+
+    @Test
+    public void testClear() throws Exception {
+        assertThrows(IllegalArgumentException.class, () -> {
+            NKey key = NKey.createServer(null);
+            key.clear();
+            key.getPrivateKey();
+
+        }, "Invalid encoding");
+    }
+}
diff --git a/src/test/java/io/nats/client/api/AccountStatisticsTests.java b/src/test/java/io/nats/client/api/AccountStatisticsTests.java
index 5af546d92..a1cb3a4bb 100644
--- a/src/test/java/io/nats/client/api/AccountStatisticsTests.java
+++ b/src/test/java/io/nats/client/api/AccountStatisticsTests.java
@@ -18,6 +18,7 @@
 
 import java.util.Map;
 
+import static io.nats.client.support.JsonUtils.EMPTY_JSON;
 import static io.nats.client.utils.ResourceUtils.dataAsString;
 import static org.junit.jupiter.api.Assertions.*;
 
@@ -58,7 +59,7 @@ public void testAccountStatsImpl() {
 
         assertNotNull(as.toString()); // COVERAGE
 
-        as = new AccountStatistics(getDataMessage("{}"));
+        as = new AccountStatistics(getDataMessage(EMPTY_JSON));
         assertEquals(0, as.getMemory());
         assertEquals(0, as.getStorage());
         assertEquals(0, as.getStreams());
diff --git a/src/test/java/io/nats/client/impl/JetStreamPullTests.java b/src/test/java/io/nats/client/impl/JetStreamPullTests.java
index 2ee176ee9..5e62e3b45 100644
--- a/src/test/java/io/nats/client/impl/JetStreamPullTests.java
+++ b/src/test/java/io/nats/client/impl/JetStreamPullTests.java
@@ -16,6 +16,7 @@
 import io.nats.client.*;
 import io.nats.client.api.AckPolicy;
 import io.nats.client.api.ConsumerConfiguration;
+import io.nats.client.support.JsonUtils;
 import io.nats.client.support.Status;
 import io.nats.client.utils.TestBase;
 import org.junit.jupiter.api.Disabled;
@@ -31,7 +32,6 @@
 
 import static io.nats.client.api.ConsumerConfiguration.builder;
 import static io.nats.client.support.ApiConstants.*;
-import static io.nats.client.support.JsonWriteUtils.*;
 import static io.nats.client.support.Status.*;
 import static org.junit.jupiter.api.Assertions.*;
 
@@ -969,11 +969,11 @@ public BadPullRequestOptions() {
 
         @Override
         public String toJson() {
-            StringBuilder sb = beginJson();
-            addField(sb, BATCH, 1);
-            addFldWhenTrue(sb, NO_WAIT, true);
-            addFieldAsNanos(sb, IDLE_HEARTBEAT, Duration.ofMillis(1));
-            return endJson(sb).toString();
+            StringBuilder sb = JsonUtils.beginJson();
+            JsonUtils.addField(sb, BATCH, 1);
+            JsonUtils.addFldWhenTrue(sb, NO_WAIT, true);
+            JsonUtils.addFieldAsNanos(sb, IDLE_HEARTBEAT, Duration.ofMillis(1));
+            return JsonUtils.endJson(sb).toString();
         }
     }
 
diff --git a/src/test/java/io/nats/client/impl/ListRequestsTests.java b/src/test/java/io/nats/client/impl/ListRequestsTests.java
index 8507fdbc7..dc94cd70c 100644
--- a/src/test/java/io/nats/client/impl/ListRequestsTests.java
+++ b/src/test/java/io/nats/client/impl/ListRequestsTests.java
@@ -22,6 +22,7 @@
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
 
+import static io.nats.client.support.JsonUtils.EMPTY_JSON;
 import static io.nats.client.utils.ResourceUtils.dataAsString;
 import static org.junit.jupiter.api.Assertions.*;
 
@@ -73,7 +74,7 @@ public void testConsumerListResponse() throws Exception {
         assertEquals(DateTimeUtils.parseDateTime("2022-06-29T20:33:21.163377Z"), sinfo.getLastActive());
 
         clr = new ConsumerListReader();
-        clr.process(getDataMessage("{}"));
+        clr.process(getDataMessage(EMPTY_JSON));
         assertEquals(0, clr.getConsumers().size());
     }
 
diff --git a/src/test/java/io/nats/client/support/DateTimeUtilsTests.java b/src/test/java/io/nats/client/support/DateTimeUtilsTests.java
new file mode 100644
index 000000000..27e2520ac
--- /dev/null
+++ b/src/test/java/io/nats/client/support/DateTimeUtilsTests.java
@@ -0,0 +1,86 @@
+// Copyright 2015-2018 The NATS Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at:
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package io.nats.client.support;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public final class DateTimeUtilsTests {
+
+    @Test
+    public void testParseDateTime() {
+        assertEquals(1611186068, DateTimeUtils.parseDateTime("2021-01-20T23:41:08.579594Z").toEpochSecond());
+        assertEquals(1612293508, DateTimeUtils.parseDateTime("2021-02-02T11:18:28.347722551-08:00").toEpochSecond());
+        assertEquals(DateTimeUtils.DEFAULT_TIME, DateTimeUtils.parseDateTime("anything-not-valid"));
+
+        ZonedDateTime zdt1 = DateTimeUtils.parseDateTime("2021-01-20T18:41:08-05:00");
+        ZonedDateTime zdt2 = DateTimeUtils.parseDateTime("2021-01-20T23:41:08.000000Z");
+        assertEquals(zdt1, zdt2);
+
+        zdt1 = ZonedDateTime.of(2012, 1, 12, 6, 30, 1, 500, DateTimeUtils.ZONE_ID_GMT);
+        assertEquals(zdt1.toEpochSecond(), DateTimeUtils.parseDateTime("2012-01-12T06:30:01.000000500Z").toEpochSecond());
+    }
+
+    @Test
+    public void testToRfc3339() {
+        Instant i = Instant.ofEpochSecond(1611186068);
+        ZonedDateTime zdt1 = ZonedDateTime.ofInstant(i, ZoneId.systemDefault());
+        ZonedDateTime zdt2 = ZonedDateTime.ofInstant(i, DateTimeUtils.ZONE_ID_GMT);
+        System.out.println(zdt1);
+        System.out.println(zdt2);
+        assertEquals(zdt1.toEpochSecond(), zdt2.toEpochSecond());
+
+        String rfc1 = DateTimeUtils.toRfc3339(zdt1);
+        String rfc2 = DateTimeUtils.toRfc3339(zdt2);
+        assertEquals(rfc1, rfc2);
+        System.out.println(zdt2.toEpochSecond());
+
+        assertEquals("2021-01-20T23:41:08.579594000Z", DateTimeUtils.toRfc3339(DateTimeUtils.parseDateTime("2021-01-20T23:41:08.579594Z")));
+        assertEquals("2021-02-02T19:18:28.347722551Z", DateTimeUtils.toRfc3339(DateTimeUtils.parseDateTime("2021-02-02T11:18:28.347722551-08:00")));
+    }
+
+    @Test
+    public void testFromNow() {
+        long now = Instant.now().toEpochMilli();
+        long then = Instant.from(DateTimeUtils.fromNow(5000)).toEpochMilli();
+        assertTrue(then - now < 5050); // it takes about 10 ms to execute fromNow
+
+        now = Instant.now().toEpochMilli();
+        then = Instant.from(DateTimeUtils.fromNow(Duration.ofMillis(5000))).toEpochMilli();
+        assertTrue(then - now < 5050);
+    }
+
+    @Test
+    public void testEquals() {
+        Instant i = Instant.ofEpochSecond(System.currentTimeMillis());
+        ZonedDateTime zdt1 = ZonedDateTime.ofInstant(i, ZoneId.of("America/New_York"));
+        ZonedDateTime zdt2 = ZonedDateTime.ofInstant(i, DateTimeUtils.ZONE_ID_GMT);
+        assertTrue(DateTimeUtils.equals(zdt1, zdt1));
+        assertTrue(DateTimeUtils.equals(zdt1, zdt2));
+        assertFalse(DateTimeUtils.equals(zdt1, null));
+        assertFalse(DateTimeUtils.equals(null, zdt2));
+
+        i = Instant.ofEpochSecond(System.currentTimeMillis() - (1000 * 60 * 60 * 24));
+        ZonedDateTime zdt3 = ZonedDateTime.ofInstant(i, ZoneId.of("America/New_York"));
+        ZonedDateTime zdt4 = ZonedDateTime.ofInstant(i, DateTimeUtils.ZONE_ID_GMT);
+        assertFalse(DateTimeUtils.equals(zdt3, zdt1));
+        assertFalse(DateTimeUtils.equals(zdt4, zdt1));
+    }
+}
diff --git a/src/test/java/io/nats/client/support/EncodingTests.java b/src/test/java/io/nats/client/support/EncodingTests.java
index d5e4a185f..fce7467c5 100644
--- a/src/test/java/io/nats/client/support/EncodingTests.java
+++ b/src/test/java/io/nats/client/support/EncodingTests.java
@@ -17,12 +17,9 @@
 
 import java.util.List;
 
-import static io.nats.client.NKeyUtils.base32Decode;
-import static io.nats.client.NKeyUtils.base32Encode;
 import static io.nats.client.support.Encoding.jsonDecode;
 import static io.nats.client.support.Encoding.jsonEncode;
 import static io.nats.client.utils.ResourceUtils.dataAsLines;
-import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
 public final class EncodingTests {
@@ -70,10 +67,5 @@ private void _testEncodeDecode(String encodedInput, String targetDecode, String
         else {
             assertEquals(targetEncode, encoded);
         }
-
-        byte[] testBytes = decoded.getBytes();
-        char[] e32 = base32Encode(testBytes);
-        byte[] d32 = base32Decode(e32);
-        assertArrayEquals(testBytes, d32);
     }
 }
diff --git a/src/test/java/io/nats/client/support/JsonParsingTests.java b/src/test/java/io/nats/client/support/JsonParsingTests.java
new file mode 100644
index 000000000..fa0795ff8
--- /dev/null
+++ b/src/test/java/io/nats/client/support/JsonParsingTests.java
@@ -0,0 +1,950 @@
+// Copyright 2023 The NATS Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at:
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package io.nats.client.support;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+import nl.jqno.equalsverifier.Warning;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.time.DateTimeException;
+import java.time.Duration;
+import java.time.ZonedDateTime;
+import java.util.*;
+
+import static io.nats.client.support.Encoding.jsonEncode;
+import static io.nats.client.support.JsonParser.*;
+import static io.nats.client.support.JsonParser.Option.KEEP_NULLS;
+import static io.nats.client.support.JsonValueUtils.*;
+import static io.nats.client.utils.ResourceUtils.dataAsLines;
+import static io.nats.client.utils.TestBase.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+public final class JsonParsingTests {
+
+    @Test
+    public void testStringParsing() {
+        List<String> encodeds = new ArrayList<>();
+        List<String> decodeds = new ArrayList<>();
+        Map<String, JsonValue> oMap = new HashMap<>();
+        List<JsonValue> list = new ArrayList<>();
+
+        int x = 0;
+        addField(key(x++), "b4\\after", oMap, list, encodeds, decodeds);
+        addField(key(x++), "b4/after", oMap, list, encodeds, decodeds);
+        addField(key(x++), "b4\"after", oMap, list, encodeds, decodeds);
+        addField(key(x++), "b4\tafter", oMap, list, encodeds, decodeds);
+        addField(key(x++), "b4\\bafter", oMap, list, encodeds, decodeds);
+        addField(key(x++), "b4\\fafter", oMap, list, encodeds, decodeds);
+        addField(key(x++), "b4\\nafter", oMap, list, encodeds, decodeds);
+        addField(key(x++), "b4\\rafter", oMap, list, encodeds, decodeds);
+        addField(key(x++), "b4\\tafter", oMap, list, encodeds, decodeds);
+        addField(key(x++), "b4" + (char) 0 + "after", oMap, list, encodeds, decodeds);
+        addField(key(x++), "b4" + (char) 1 + "after", oMap, list, encodeds, decodeds);
+
+        List<String> utfs = dataAsLines("utf8-only-no-ws-test-strings.txt");
+        for (String u : utfs) {
+            String uu = "b4\b\f\n\r\t" + u + "after";
+            addField(key(x++), uu, oMap, list, encodeds, decodeds);
+        }
+
+        addField(key(x++), PLAIN, oMap, list, encodeds, decodeds);
+        addField(key(x++), HAS_SPACE, oMap, list, encodeds, decodeds);
+        addField(key(x++), HAS_PRINTABLE, oMap, list, encodeds, decodeds);
+        addField(key(x++), HAS_DOT, oMap, list, encodeds, decodeds);
+        addField(key(x++), STAR_NOT_SEGMENT, oMap, list, encodeds, decodeds);
+        addField(key(x++), GT_NOT_SEGMENT, oMap, list, encodeds, decodeds);
+        addField(key(x++), HAS_DASH, oMap, list, encodeds, decodeds);
+        addField(key(x++), HAS_UNDER, oMap, list, encodeds, decodeds);
+        addField(key(x++), HAS_DOLLAR, oMap, list, encodeds, decodeds);
+        addField(key(x++), HAS_LOW, oMap, list, encodeds, decodeds);
+        addField(key(x++), HAS_127, oMap, list, encodeds, decodeds);
+        addField(key(x++), HAS_FWD_SLASH, oMap, list, encodeds, decodeds);
+        addField(key(x++), HAS_BACK_SLASH, oMap, list, encodeds, decodeds);
+        addField(key(x++), HAS_EQUALS, oMap, list, encodeds, decodeds);
+        addField(key(x++), HAS_TIC, oMap, list, encodeds, decodeds);
+
+        for (int i = 0; i < list.size(); i++) {
+            JsonValue v = list.get(i);
+            assertEquals(decodeds.get(i), v.string);
+            assertEquals(v.toJson(), "\"" + encodeds.get(i) + "\"");
+        }
+    }
+
+    private void addField(String name, String decoded,
+                          Map<String, JsonValue> map, List<JsonValue> list,
+                          List<String> encodeds, List<String> decodeds) {
+        String enc = jsonEncode(decoded);
+        encodeds.add(enc);
+        decodeds.add(decoded);
+        JsonValue jv = new JsonValue(decoded);
+        map.put(name, jv);
+        list.add(jv);
+    }
+
+    @SuppressWarnings("UnnecessaryUnicodeEscape")
+    @Test
+    public void testJsonValuePrimitives() throws JsonParseException {
+        Map<String, JsonValue> oMap = new HashMap<>();
+        oMap.put("trueKey1", new JsonValue(true));
+        oMap.put("trueKey2", new JsonValue(Boolean.TRUE));
+        oMap.put("falseKey1", new JsonValue(false));
+        oMap.put("falseKey2", new JsonValue(Boolean.FALSE));
+        oMap.put("stringKey", new JsonValue("hello world!"));
+        oMap.put("escapeStringKey", new JsonValue("h\be\tllo w\u1234orld!"));
+        oMap.put("nullKey", JsonValue.NULL);
+        oMap.put("intKey1", new JsonValue(Integer.MAX_VALUE));
+        oMap.put("intKey2", new JsonValue(Integer.MIN_VALUE));
+        oMap.put("longKey1", new JsonValue(Long.MAX_VALUE));
+        oMap.put("longKey2", new JsonValue(Long.MIN_VALUE));
+        oMap.put("doubleKey1", new JsonValue(Double.MAX_VALUE));
+        oMap.put("doubleKey2", new JsonValue(Double.MIN_VALUE));
+        oMap.put("floatKey1", new JsonValue(Float.MAX_VALUE));
+        oMap.put("floatKey2", new JsonValue(Float.MIN_VALUE));
+        oMap.put("bigDecimalKey1", new JsonValue(new BigDecimal("9223372036854775807.123")));
+        oMap.put("bigDecimalKey2", new JsonValue(new BigDecimal("-9223372036854775808.123")));
+        oMap.put("bigIntegerKey1", new JsonValue(new BigInteger("9223372036854775807")));
+        oMap.put("bigIntegerKey2", new JsonValue(new BigInteger("-9223372036854775808")));
+
+        // some coverage here
+        JsonValue vMap = new JsonValue(oMap);
+        assertEquals(vMap.toJson(), vMap.toString());
+
+        validateMapTypes(oMap, oMap, true);
+
+        // don't keep nulls
+        JsonValue parsed = parse(new JsonValue(oMap).toJson());
+        assertNotNull(parsed.map);
+        assertEquals(oMap.size() - 1, parsed.map.size());
+        validateMapTypes(parsed.map, oMap, false);
+
+        // keep nulls
+        parsed = parse(new JsonValue(oMap).toJson(), KEEP_NULLS);
+        assertNotNull(parsed.map);
+        assertEquals(oMap.size(), parsed.map.size());
+        validateMapTypes(parsed.map, oMap, true);
+    }
+
+    private static void validateMapTypes(Map<String, JsonValue> map, Map<String, JsonValue> oMap, boolean original) {
+        assertEquals(JsonValue.Type.BOOL, map.get("trueKey1").type);
+        assertEquals(JsonValue.Type.BOOL, map.get("trueKey2").type);
+        assertEquals(JsonValue.Type.BOOL, map.get("falseKey1").type);
+        assertEquals(JsonValue.Type.BOOL, map.get("falseKey2").type);
+        assertEquals(JsonValue.Type.STRING, map.get("stringKey").type);
+        assertEquals(JsonValue.Type.STRING, map.get("escapeStringKey").type);
+        assertEquals(JsonValue.Type.INTEGER, map.get("intKey1").type);
+        assertEquals(JsonValue.Type.INTEGER, map.get("intKey2").type);
+        assertEquals(JsonValue.Type.LONG, map.get("longKey1").type);
+        assertEquals(JsonValue.Type.LONG, map.get("longKey2").type);
+
+        assertNotNull(map.get("trueKey1").bool);
+        assertNotNull(map.get("trueKey2").bool);
+        assertNotNull(map.get("falseKey1").bool);
+        assertNotNull(map.get("falseKey2").bool);
+        assertNotNull(map.get("stringKey").string);
+        assertNotNull(map.get("escapeStringKey").string);
+        assertNotNull(map.get("intKey1").i);
+        assertNotNull(map.get("intKey2").i);
+        assertNotNull(map.get("longKey1").l);
+        assertNotNull(map.get("longKey2").l);
+
+        assertEquals(oMap.get("trueKey1"), map.get("trueKey1"));
+        assertEquals(oMap.get("trueKey2"), map.get("trueKey2"));
+        assertEquals(oMap.get("falseKey1"), map.get("falseKey1"));
+        assertEquals(oMap.get("falseKey2"), map.get("falseKey2"));
+        assertEquals(oMap.get("stringKey"), map.get("stringKey"));
+        assertEquals(oMap.get("escapeStringKey"), map.get("escapeStringKey"));
+        assertEquals(oMap.get("intKey1"), map.get("intKey1"));
+        assertEquals(oMap.get("intKey2"), map.get("intKey2"));
+        assertEquals(oMap.get("longKey1"), map.get("longKey1"));
+        assertEquals(oMap.get("longKey2"), map.get("longKey2"));
+
+        if (original) {
+            assertNotNull(oMap.get("intKey1").i);
+            assertNotNull(oMap.get("intKey2").i);
+            assertNotNull(oMap.get("longKey1").l);
+            assertNotNull(oMap.get("longKey2").l);
+            assertNotNull(oMap.get("doubleKey1").d);
+            assertNotNull(oMap.get("doubleKey2").d);
+            assertNotNull(oMap.get("floatKey1").f);
+            assertNotNull(oMap.get("floatKey2").f);
+            assertNotNull(oMap.get("bigDecimalKey1").bd);
+            assertNotNull(oMap.get("bigDecimalKey2").bd);
+            assertNotNull(oMap.get("bigIntegerKey1").bi);
+            assertNotNull(oMap.get("bigIntegerKey2").bi);
+
+            assertEquals(JsonValue.Type.NULL, map.get("nullKey").type);
+            assertNull(map.get("nullKey").object);
+            assertEquals(oMap.get("nullKey"), map.get("nullKey"));
+        }
+        else {
+            assertNotNull(oMap.get("intKey1").number);
+            assertNotNull(oMap.get("intKey2").number);
+            assertNotNull(oMap.get("longKey1").number);
+            assertNotNull(oMap.get("longKey2").number);
+            assertNotNull(oMap.get("doubleKey1").number);
+            assertNotNull(oMap.get("doubleKey2").number);
+            assertNotNull(oMap.get("floatKey1").number);
+            assertNotNull(oMap.get("floatKey2").number);
+            assertNotNull(oMap.get("bigDecimalKey1").number);
+            assertNotNull(oMap.get("bigDecimalKey2").number);
+            assertNotNull(oMap.get("bigIntegerKey1").number);
+            assertNotNull(oMap.get("bigIntegerKey2").number);
+        }
+    }
+
+    @Test
+    public void testArray() throws JsonParseException {
+        List<JsonValue> list = new ArrayList<>();
+        list.add(new JsonValue("string"));
+        list.add(new JsonValue(true));
+        list.add(JsonValue.NULL);
+        list.add(JsonValue.EMPTY_MAP);
+        list.add(JsonValue.EMPTY_ARRAY);
+
+        JsonValue root = parse(new JsonValue(list).toJson());
+        assertNotNull(root.array);
+        assertEquals(list.size(), root.array.size());
+        List<JsonValue> array = root.array;
+        for (int i = 0; i < array.size(); i++) {
+            JsonValue v = array.get(i);
+            JsonValue p = root.array.get(i);
+            assertEquals(v.object, p.object);
+            assertTrue(list.contains(v));
+        }
+
+
+        list.clear();
+        list.add(new JsonValue(1));
+        list.add(new JsonValue(Long.MAX_VALUE));
+        list.add(new JsonValue(Double.MAX_VALUE));
+        list.add(new JsonValue(Float.MAX_VALUE));
+        list.add(new JsonValue(new BigDecimal(Double.toString(Double.MAX_VALUE))));
+        list.add(new JsonValue(new BigInteger(Long.toString(Long.MAX_VALUE))));
+
+        root = parse(new JsonValue(list).toJson());
+        assertNotNull(root.array);
+        assertEquals(list.size(), root.array.size());
+        array = root.array;
+        for (int i = 0; i < array.size(); i++) {
+            JsonValue v = array.get(i);
+            JsonValue p = root.array.get(i);
+            assertEquals(v.object, p.object);
+            assertEquals(v.number, p.number);
+        }
+
+        Map<String, JsonValue> rootMap = new HashMap<>();
+        rootMap.put("list", new JsonValue(list));
+        rootMap.put("array", new JsonValue(list.toArray(new JsonValue[0])));
+        root = new JsonValue(rootMap);
+        List<JsonValue> mappedList = readValue(root, "list").array;
+
+        List<JsonValue> mappedList2 = parse(new JsonValue(mappedList).toJson()).array;
+        List<JsonValue> mappedArray = readValue(root, "array").array;
+        List<JsonValue> mappedArray2 = parse(new JsonValue(list.toArray(new JsonValue[0])).toJson()).array;
+        for (int i = 0; i < list.size(); i++) {
+            JsonValue v = list.get(i);
+            JsonValue lv = mappedList.get(i);
+            JsonValue lv2 = mappedList2.get(i);
+            JsonValue av = mappedArray.get(i);
+            JsonValue av2 = mappedArray2.get(i);
+            assertNotNull(lv);
+            assertNotNull(lv2);
+            assertNotNull(av);
+            assertNotNull(av2);
+            assertEquals(v, lv);
+            assertEquals(v, av);
+
+            // conversions are not perfect for doubles and floats, but that's a java thing, not a parser thing
+            if (v.type == lv2.type) {
+                assertEquals(v, lv2);
+            }
+            if (v.type == av2.type) {
+                assertEquals(v, av2);
+            }
+        }
+    }
+
+    @Test
+    public void testListReading() {
+        List<JsonValue> jvList = new ArrayList<>();
+        jvList.add(new JsonValue("string1"));
+        jvList.add(new JsonValue("string2"));
+        jvList.add(new JsonValue(""));
+        jvList.add(new JsonValue(true));
+        jvList.add(new JsonValue((String)null));
+        jvList.add(JsonValue.NULL);
+        jvList.add(JsonValue.EMPTY_MAP);
+        jvList.add(JsonValue.EMPTY_ARRAY);
+        jvList.add(new JsonValue(Integer.MAX_VALUE));
+        jvList.add(new JsonValue(Long.MAX_VALUE));
+        Map<String, JsonValue> jvMap = new HashMap<>();
+        jvMap.put("list", new JsonValue(jvList));
+        JsonValue root = new JsonValue(jvMap);
+
+        List<String> list = readStringList(root, "list");
+        assertEquals(3, list.size());
+        assertTrue(list.contains("string1"));
+        assertTrue(list.contains("string2"));
+        assertTrue(list.contains(""));
+
+        list = readStringListIgnoreEmpty(root, "list");
+        assertEquals(2, list.size());
+        assertTrue(list.contains("string1"));
+        assertTrue(list.contains("string2"));
+
+        jvList.remove(0);
+        jvList.remove(0);
+        jvList.remove(0);
+        list = readOptionalStringList(root, "list");
+        assertNull(list);
+
+        list = readOptionalStringList(root, "na");
+        assertNull(list);
+
+        jvList.clear();
+        Duration d0 = Duration.ofNanos(10000000000L);
+        Duration d1 = Duration.ofNanos(20000000000L);
+        Duration d2 = Duration.ofNanos(30000000000L);
+
+        jvList.add(instance(d0));
+        jvList.add(instance(d1));
+        jvList.add(instance(d2));
+        jvList.add(new JsonValue("not duration nanos"));
+
+        root = new JsonValue(jvMap);
+
+        List<Duration> dlist = readNanosList(root, "list");
+        assertEquals(3, dlist.size());
+        assertEquals(d0, dlist.get(0));
+        assertEquals(d1, dlist.get(1));
+        assertEquals(d2, dlist.get(2));
+    }
+
+    @Test
+    public void testGetIntLong() {
+        JsonValue i = new JsonValue(Integer.MAX_VALUE);
+        JsonValue li = new JsonValue((long)Integer.MAX_VALUE);
+        JsonValue lmax = new JsonValue(Long.MAX_VALUE);
+        JsonValue lmin = new JsonValue(Long.MIN_VALUE);
+        assertEquals(Integer.MAX_VALUE, getInteger(i));
+        assertEquals(Integer.MAX_VALUE, getInteger(li));
+        assertNull(getInteger(lmax));
+        assertNull(getInteger(lmin));
+        assertNull(getInteger(JsonValue.NULL));
+        assertNull(getInteger(JsonValue.EMPTY_MAP));
+        assertNull(getInteger(JsonValue.EMPTY_ARRAY));
+
+        assertEquals(Integer.MAX_VALUE, getLong(i));
+        assertEquals(Integer.MAX_VALUE, getLong(li));
+        assertEquals(Long.MAX_VALUE, getLong(lmax));
+        assertEquals(Long.MIN_VALUE, getLong(lmin));
+        assertNull(getLong(JsonValue.NULL));
+        assertNull(getLong(JsonValue.EMPTY_MAP));
+        assertNull(getLong(JsonValue.EMPTY_ARRAY));
+
+        assertEquals(Integer.MAX_VALUE, getLong(i, -1));
+        assertEquals(Integer.MAX_VALUE, getLong(li, -1));
+        assertEquals(Long.MAX_VALUE, getLong(lmax, -1));
+        assertEquals(Long.MIN_VALUE, getLong(lmin, -1));
+        assertEquals(-1, getLong(JsonValue.NULL, -1));
+        assertEquals(-1, getLong(JsonValue.EMPTY_MAP, -1));
+        assertEquals(-1, getLong(JsonValue.EMPTY_ARRAY, -1));
+    }
+
+    @Test
+    public void testConstantsAreReadOnly() {
+        assertThrows(UnsupportedOperationException.class, () -> JsonValue.EMPTY_MAP.map.put("foo", null));
+        assertThrows(UnsupportedOperationException.class, () -> JsonValue.EMPTY_ARRAY.array.add(null));
+    }
+
+    @Test
+    public void testNullJsonValue() {
+        assertEquals(JsonValue.Type.NULL, JsonValue.NULL.type);
+        assertNull(JsonValue.NULL.object);
+        assertNull(JsonValue.NULL.map);
+        assertNull(JsonValue.NULL.array);
+        assertNull(JsonValue.NULL.string);
+        assertNull(JsonValue.NULL.bool);
+        assertNull(JsonValue.NULL.number);
+        assertNull(JsonValue.NULL.i);
+        assertNull(JsonValue.NULL.l);
+        assertNull(JsonValue.NULL.d);
+        assertNull(JsonValue.NULL.f);
+        assertNull(JsonValue.NULL.bd);
+        assertNull(JsonValue.NULL.bi);
+        assertEquals(JsonValue.NULL, new JsonValue((String)null));
+        assertEquals(JsonValue.NULL, new JsonValue((Boolean) null));
+        assertEquals(JsonValue.NULL, new JsonValue((Map<String, JsonValue>)null));
+        assertEquals(JsonValue.NULL, new JsonValue((List<JsonValue>)null));
+        assertEquals(JsonValue.NULL, new JsonValue((JsonValue[])null));
+        assertEquals(JsonValue.NULL, new JsonValue((BigDecimal)null));
+        assertEquals(JsonValue.NULL, new JsonValue((BigInteger) null));
+    }
+
+    @Test
+    public void testGetMapped() {
+        ZonedDateTime zdt = DateTimeUtils.gmtNow();
+        Duration dur = Duration.ofNanos(4273);
+        Duration dur2 = Duration.ofNanos(7342);
+
+        JsonValue v = new JsonValue(new HashMap<>());
+        v.map.put("bool", new JsonValue(Boolean.TRUE));
+        v.map.put("string", new JsonValue("hello"));
+        v.map.put("int", new JsonValue(Integer.MAX_VALUE));
+        v.map.put("long", new JsonValue(Long.MAX_VALUE));
+        v.map.put("date", new JsonValue(DateTimeUtils.toRfc3339(zdt)));
+        v.map.put("dur", new JsonValue(dur.toNanos()));
+        v.map.put("strings", new JsonValue(new JsonValue[]{new JsonValue("s1"), new JsonValue("s2")}));
+        v.map.put("durs", new JsonValue(new JsonValue[]{new JsonValue(dur.toNanos()), new JsonValue(dur2.toNanos())}));
+
+        assertNotNull(readValue(v, "string"));
+        assertNull(readValue(v, "na"));
+        assertEquals(JsonValue.EMPTY_MAP, readObject(v, "na"));
+        assertNull(read(null, "na", vv -> vv));
+        assertNull(read(JsonValue.NULL, "na", vv -> vv));
+        assertNull(read(JsonValue.EMPTY_MAP, "na", vv -> vv));
+
+        assertNull(readDate(null, "na"));
+        assertNull(readDate(JsonValue.NULL, "na"));
+        assertNull(readDate(JsonValue.EMPTY_MAP, "na"));
+        assertEquals(zdt, readDate(v, "date"));
+        assertNull(readDate(v, "int"));
+
+        assertFalse(readBoolean(null, "na"));
+        assertFalse(readBoolean(null, "na", false));
+        assertTrue(readBoolean(null, "na", true));
+        assertFalse(readBoolean(JsonValue.NULL, "na"));
+        assertFalse(readBoolean(JsonValue.NULL, "na", false));
+        assertTrue(readBoolean(JsonValue.NULL, "na", true));
+        assertFalse(readBoolean(JsonValue.EMPTY_MAP, "na"));
+        assertFalse(readBoolean(JsonValue.EMPTY_MAP, "na", false));
+        assertTrue(readBoolean(JsonValue.EMPTY_MAP, "na", true));
+        assertFalse(readBoolean(v, "na"));
+        assertFalse(readBoolean(v, "na", false));
+        assertTrue(readBoolean(v, "na", true));
+        assertFalse(readBoolean(v, "int"));
+        assertFalse(readBoolean(v, "int", false));
+        assertTrue(readBoolean(v, "int", true));
+
+        assertTrue(readBoolean(v, "bool"));
+        assertTrue(readBoolean(v, "bool", false));
+        assertFalse(readBoolean(v, "na"));
+        assertFalse(readBoolean(v, "na", false));
+        assertTrue(readBoolean(v, "na", true));
+
+        assertEquals("hello", readString(v, "string"));
+        assertEquals("hello", readString(v, "string", null));
+        assertNull(readString(v, "na"));
+        assertNull(readString(v, "na", null));
+        assertEquals("default", readString(v, "na", "default"));
+        assertNull(readString(JsonValue.NULL, "na"));
+        assertNull(readString(JsonValue.NULL, "na", null));
+        assertEquals("default", readString(JsonValue.NULL, "na", "default"));
+
+        assertEquals(zdt, readDate(v, "date"));
+        assertNull(readDate(v, "na"));
+        assertThrows(DateTimeException.class, () -> readDate(v, "string"));
+
+        assertEquals(Integer.MAX_VALUE, readInteger(v, "int"));
+        assertEquals(Integer.MAX_VALUE, readInteger(v, "int", -1));
+        assertNull(readInteger(v, "string"));
+        assertEquals(-1, readInteger(v, "string", -1));
+        assertNull(readInteger(v, "na"));
+        assertEquals(-1, readInteger(v, "na", -1));
+
+        assertEquals(Long.MAX_VALUE, readLong(v, "long"));
+        assertEquals(Long.MAX_VALUE, readLong(v, "long", -1));
+        assertNull(readLong(v, "string"));
+        assertEquals(-1, readLong(v, "string", -1));
+        assertNull(readLong(v, "na"));
+        assertEquals(-1, readLong(v, "na", -1));
+
+        assertEquals(dur, readNanos(v, "dur"));
+        assertEquals(dur, readNanos(v, "dur", null));
+        assertNull(readNanos(v, "string"));
+        assertNull(readNanos(v, "string", null));
+        assertEquals(dur2, readNanos(v, "string", dur2));
+        assertNull(readNanos(v, "na"));
+        assertNull(readNanos(v, "na", null));
+        assertEquals(dur2, readNanos(v, "na", dur2));
+
+        // these aren't maps
+        JsonValue jvn = new JsonValue(1);
+        JsonValue jvs = new JsonValue("s");
+        JsonValue jvb = new JsonValue(true);
+        JsonValue[] notMaps = new JsonValue[] {JsonValue.NULL, JsonValue.EMPTY_ARRAY, jvn, jvs, jvb};
+
+        for (JsonValue vv : notMaps) {
+            assertNull(readValue(vv, "na"));
+            assertEquals(JsonValue.EMPTY_MAP, readObject(vv, "na"));
+            assertNull(readDate(vv, "na"));
+            assertNull(readInteger(vv, "na"));
+            assertEquals(-1, readInteger(vv, "na", -1));
+            assertNull(readLong(vv, "na"));
+            assertEquals(-2, readLong(vv, "na", -2));
+            assertFalse(readBoolean(vv, "na"));
+            assertNull(readBoolean(vv, "na", null));
+            assertTrue(readBoolean(vv, "na", true));
+            assertFalse(readBoolean(vv, "na", false));
+            assertNull(readNanos(vv, "na"));
+            assertEquals(Duration.ZERO, readNanos(vv, "na", Duration.ZERO));
+        }
+    }
+
+    @Test
+    public void equalsContract() {
+        Map<String, JsonValue> map1 = new HashMap<>();
+        map1.put("1", new JsonValue(1));
+        Map<String, JsonValue> map2 = new HashMap<>();
+        map1.put("2", new JsonValue(2));
+        List<JsonValue> list3 = new ArrayList<>();
+        list3.add(new JsonValue(3));
+        List<JsonValue> list4 = new ArrayList<>();
+        list4.add(new JsonValue(4));
+        EqualsVerifier.simple().forClass(JsonValue.class)
+            .withPrefabValues(Map.class, map1, map2)
+            .withPrefabValues(List.class, list3, list4)
+            .withIgnoredFields("object", "number", "mapOrder")
+            .suppress(Warning.BIGDECIMAL_EQUALITY)
+            .verify();
+    }
+
+    private void validateParse(JsonValue expected, String json) throws JsonParseException {
+        char[] ca = json.toCharArray();
+        byte[] ba = json.getBytes();
+
+        assertEquals(expected, parse(json));
+        assertEquals(expected, parse(json, 0));
+        assertEquals(expected, parse(json, KEEP_NULLS));
+        assertEquals(expected, parse(ca));
+        assertEquals(expected, parse(ca, 0));
+        assertEquals(expected, parse(ca, KEEP_NULLS));
+        assertEquals(expected, parse(ba));
+        assertEquals(expected, parse(ba, KEEP_NULLS));
+
+        assertEquals(expected, parseUnchecked(json));
+        assertEquals(expected, parseUnchecked(json, 0));
+        assertEquals(expected, parseUnchecked(json, KEEP_NULLS));
+        assertEquals(expected, parseUnchecked(ca));
+        assertEquals(expected, parseUnchecked(ca, 0));
+        assertEquals(expected, parseUnchecked(ca, KEEP_NULLS));
+        assertEquals(expected, parseUnchecked(ba));
+        assertEquals(expected, parseUnchecked(ba, KEEP_NULLS));
+    }
+
+    @Test
+    public void testParsingCoverage() throws JsonParseException {
+        validateParse(JsonValue.NULL, "");
+        validateParse(JsonValue.EMPTY_MAP, "{}");
+        validateParse(JsonValue.EMPTY_ARRAY, "[]");
+
+        IllegalArgumentException iae = assertThrows(IllegalArgumentException.class, () -> parse("{}", -1));
+        assertTrue(iae.getMessage().contains("Invalid start index."));
+
+        validateThrows("{", "Text must end with '}'");
+        validateThrows("{{", "Cannot directly nest another Object or Array.");
+        validateThrows("{[", "Cannot directly nest another Object or Array.");
+        validateThrows("{\"foo\":1 ]", "Expected a ',' or '}'.");
+        validateThrows("{\"foo\" 1", "Expected a ':' after a key.");
+        validateThrows("[\"bad\",", "Unexpected end of data."); // missing close
+        validateThrows("[1Z]", "Invalid value.");
+        validateThrows("t", "Invalid value.");
+        validateThrows("f", "Invalid value.");
+        validateThrows("\"u", "Unterminated string.");
+        validateThrows("\"u\r", "Unterminated string.");
+        validateThrows("\"u\n", "Unterminated string.");
+        validateThrows("\"\\x\"", "Illegal escape.");
+        validateThrows("\"\\u000", "Illegal escape.");
+        validateThrows("\"\\uzzzz", "Illegal escape.");
+
+        JsonValue v = JsonParser.parse((char[])null);
+        assertEquals(JsonValue.NULL, v);
+
+        v = parse("{\"foo\":1,}");
+        assertEquals(1, v.map.size());
+        assertTrue(v.map.containsKey("foo"));
+        assertEquals(1, v.map.get("foo").i);
+
+        v = parse("INFO{\"foo\":1,}", 4);
+        assertEquals(1, v.map.size());
+        assertTrue(v.map.containsKey("foo"));
+        assertEquals(1, v.map.get("foo").i);
+
+        v = parse("[\"foo\",]"); // handles dangling commas fine
+        assertEquals(1, v.array.size());
+        assertEquals("foo", v.array.get(0).string);
+
+        String s = "foo \b \t \n \f \r \" \\ /";
+        String j = "\"" + Encoding.jsonEncode(s) + "\"";
+        v = parse(j);
+        assertNotNull(v.string);
+        assertEquals(s, v.string);
+
+        // every constructor
+        String json = "{}";
+        new JsonParser(json.toCharArray());
+        new JsonParser(json.toCharArray(), Option.KEEP_NULLS);
+        parse(json.toCharArray());
+        parse(json.toCharArray(), 0);
+        parse(json.toCharArray(), Option.KEEP_NULLS);
+        parse(json.toCharArray(), 0, Option.KEEP_NULLS);
+        parse(json);
+        parse(json, 0);
+        parse(json, Option.KEEP_NULLS);
+        parse(json, 0, Option.KEEP_NULLS);
+        parse(json.getBytes());
+        parse(json.getBytes(), Option.KEEP_NULLS);
+        parseUnchecked(json.toCharArray());
+        parseUnchecked(json.toCharArray(), 0);
+        parseUnchecked(json.toCharArray(), Option.KEEP_NULLS);
+        parseUnchecked(json.toCharArray(), 0, Option.KEEP_NULLS);
+        parseUnchecked(json);
+        parseUnchecked(json, 0);
+        parseUnchecked(json, Option.KEEP_NULLS);
+        parseUnchecked(json, 0, Option.KEEP_NULLS);
+        parseUnchecked(json.getBytes());
+        parseUnchecked(json.getBytes(), Option.KEEP_NULLS);
+    }
+
+    private void validateThrows(String json, String errorText) {
+        // also provides coverage for every constructor
+        validateThrowError(errorText, assertThrows(JsonParseException.class, () -> parse(json.toCharArray())));
+        validateThrowError(errorText, assertThrows(JsonParseException.class, () -> parse(json.toCharArray(), 0)));
+        validateThrowError(errorText, assertThrows(JsonParseException.class, () -> parse(json.toCharArray(), Option.KEEP_NULLS)));
+        validateThrowError(errorText, assertThrows(JsonParseException.class, () -> parse(json.toCharArray(), 0, Option.KEEP_NULLS)));
+        validateThrowError(errorText, assertThrows(JsonParseException.class, () -> parse(json)));
+        validateThrowError(errorText, assertThrows(JsonParseException.class, () -> parse(json, 0)));
+        validateThrowError(errorText, assertThrows(JsonParseException.class, () -> parse(json, Option.KEEP_NULLS)));
+        validateThrowError(errorText, assertThrows(JsonParseException.class, () -> parse(json, 0, Option.KEEP_NULLS)));
+        validateThrowError(errorText, assertThrows(JsonParseException.class, () -> parse(json.getBytes())));
+        validateThrowError(errorText, assertThrows(JsonParseException.class, () -> parse(json.getBytes(), Option.KEEP_NULLS)));
+        validateThrowError(errorText, assertThrows(RuntimeException.class, () -> parseUnchecked(json.toCharArray())));
+        validateThrowError(errorText, assertThrows(RuntimeException.class, () -> parseUnchecked(json.toCharArray(), 0)));
+        validateThrowError(errorText, assertThrows(RuntimeException.class, () -> parseUnchecked(json.toCharArray(), Option.KEEP_NULLS)));
+        validateThrowError(errorText, assertThrows(RuntimeException.class, () -> parseUnchecked(json.toCharArray(), 0, Option.KEEP_NULLS)));
+        validateThrowError(errorText, assertThrows(RuntimeException.class, () -> parseUnchecked(json)));
+        validateThrowError(errorText, assertThrows(RuntimeException.class, () -> parseUnchecked(json, 0)));
+        validateThrowError(errorText, assertThrows(RuntimeException.class, () -> parseUnchecked(json, Option.KEEP_NULLS)));
+        validateThrowError(errorText, assertThrows(RuntimeException.class, () -> parseUnchecked(json, 0, Option.KEEP_NULLS)));
+        validateThrowError(errorText, assertThrows(RuntimeException.class, () -> parseUnchecked(json.getBytes())));
+        validateThrowError(errorText, assertThrows(RuntimeException.class, () -> parseUnchecked(json.getBytes(), Option.KEEP_NULLS)));
+    }
+
+    private static void validateThrowError(String errorText, Exception e) {
+        assertTrue(e.getMessage().contains(errorText));
+    }
+
+    @Test
+    public void testNumberParsing() throws JsonParseException {
+        assertEquals(JsonValue.Type.INTEGER, parse("1").type);
+        assertEquals(JsonValue.Type.INTEGER, parse(Integer.toString(Integer.MAX_VALUE)).type);
+        assertEquals(JsonValue.Type.INTEGER, parse(Integer.toString(Integer.MIN_VALUE)).type);
+        assertEquals(JsonValue.Type.LONG, parse(Long.toString((long)Integer.MAX_VALUE + 1)).type);
+        assertEquals(JsonValue.Type.LONG, parse(Long.toString((long)Integer.MIN_VALUE - 1)).type);
+        assertEquals(JsonValue.Type.DOUBLE, parse("-0").type);
+        assertEquals(JsonValue.Type.DOUBLE, parse("-0.0").type);
+        assertEquals(JsonValue.Type.DOUBLE, parse("0.1d").type);
+        assertEquals(JsonValue.Type.DOUBLE, parse("0.f").type);
+        assertEquals(JsonValue.Type.DOUBLE, parse("0.1f").type);
+        assertEquals(JsonValue.Type.DOUBLE, parse("-0x1.fffp1").type);
+        assertEquals(JsonValue.Type.DOUBLE, parse("0x1.0P-1074").type);
+        assertEquals(JsonValue.Type.BIG_DECIMAL, parse("0.2").type);
+        assertEquals(JsonValue.Type.BIG_DECIMAL, parse("244273.456789012345").type);
+        assertEquals(JsonValue.Type.BIG_DECIMAL, parse("244273.456789012345").type);
+        assertEquals(JsonValue.Type.BIG_DECIMAL, parse("0.1234567890123456789").type);
+        assertEquals(JsonValue.Type.BIG_DECIMAL, parse("-24.42e7345").type);
+        assertEquals(JsonValue.Type.BIG_DECIMAL, parse("-24.42E7345").type);
+        assertEquals(JsonValue.Type.BIG_DECIMAL, parse("-.01").type);
+        assertEquals(JsonValue.Type.BIG_DECIMAL, parse("00.001").type);
+        assertEquals(JsonValue.Type.BIG_INTEGER, parse("12345678901234567890").type);
+
+        String str = new BigInteger( Long.toString(Long.MAX_VALUE) ).add( BigInteger.ONE ).toString();
+        assertEquals(JsonValue.Type.BIG_INTEGER, parse(str).type);
+
+        validateThrows("-0x123", "Invalid value.");
+        JsonParseException e;
+
+        e = assertThrows(JsonParseException.class, () -> parse("-"));
+        assertTrue(e.getMessage().contains("Invalid value."));
+
+        e = assertThrows(JsonParseException.class, () -> parse("00"));
+        assertTrue(e.getMessage().contains("Invalid value."));
+
+        e = assertThrows(JsonParseException.class, () -> parse("NaN"));
+        assertTrue(e.getMessage().contains("Invalid value."));
+
+        e = assertThrows(JsonParseException.class, () -> parse("-NaN"));
+        assertTrue(e.getMessage().contains("Invalid value."));
+
+        e = assertThrows(JsonParseException.class, () -> parse("Infinity"));
+        assertTrue(e.getMessage().contains("Invalid value."));
+
+        e = assertThrows(JsonParseException.class, () -> parse("-Infinity"));
+        assertTrue(e.getMessage().contains("Invalid value."));
+    }
+
+    @Test
+    public void testValueUtilsInstanceDuration() {
+        JsonValue v = instance(Duration.ofSeconds(1));
+        assertNotNull(v.l);
+        assertEquals(1000000000L, v.l);
+    }
+
+    static class TestSerializableMap implements JsonSerializable {
+        @Override
+        public String toJson() {
+            JsonValue v = new JsonValue(new HashMap<>());
+            v.map.put("a", new JsonValue("A"));
+            v.map.put("b", new JsonValue("B"));
+            v.map.put("c", new JsonValue("C"));
+            return v.toJson();
+        }
+    }
+
+    static class TestSerializableList implements JsonSerializable {
+        @Override
+        public String toJson() {
+            JsonValue v = new JsonValue(new ArrayList<>());
+            v.array.add(new JsonValue("X"));
+            v.array.add(new JsonValue("Y"));
+            v.array.add(new JsonValue("Z"));
+            return v.toJson();
+        }
+    }
+
+    @Test
+    public void testValueUtilsInstanceList() {
+        List<Object> list = new ArrayList<>();
+        list.add("Hello");
+        list.add("");
+        list.add('c');
+        list.add(1);
+        list.add(1L);
+        list.add(1D);
+        list.add(1F);
+        list.add(new BigDecimal("1.0"));
+        list.add(new BigInteger("1"));
+        list.add(true);
+        list.add(new HashMap<>());
+        list.add(new ArrayList<>());
+        list.add(new HashSet<>());
+        list.add(new TestSerializableMap());
+        list.add(new TestSerializableList());
+        list.add(null);
+        JsonValue v = instance(list);
+        assertNotNull(v.array);
+        assertEquals(16, v.array.size());
+        assertEquals(JsonValue.Type.STRING, v.array.get(0).type);
+        assertEquals(JsonValue.Type.NULL, v.array.get(1).type);
+        assertEquals(JsonValue.Type.STRING, v.array.get(2).type);
+        assertEquals(JsonValue.Type.INTEGER, v.array.get(3).type);
+        assertEquals(JsonValue.Type.LONG, v.array.get(4).type);
+        assertEquals(JsonValue.Type.DOUBLE, v.array.get(5).type);
+        assertEquals(JsonValue.Type.FLOAT, v.array.get(6).type);
+        assertEquals(JsonValue.Type.BIG_DECIMAL, v.array.get(7).type);
+        assertEquals(JsonValue.Type.BIG_INTEGER, v.array.get(8).type);
+        assertEquals(JsonValue.Type.BOOL, v.array.get(9).type);
+        assertEquals(JsonValue.Type.MAP, v.array.get(10).type);
+        assertEquals(JsonValue.Type.ARRAY, v.array.get(11).type);
+        assertEquals(JsonValue.Type.ARRAY, v.array.get(12).type);
+        assertEquals(JsonValue.Type.MAP, v.array.get(13).type);
+        assertEquals(JsonValue.Type.ARRAY, v.array.get(14).type);
+        assertEquals(JsonValue.Type.NULL, v.array.get(15).type);
+    }
+
+    @Test
+    public void testValueUtilsInstanceMap() {
+        Map<Object, Object> map = new HashMap<>();
+        map.put("string", "Hello");
+        map.put("char", 'c');
+        map.put("int", 1);
+        map.put("long", Long.MAX_VALUE);
+        map.put("double", 1D);
+        map.put("float", 1F);
+        map.put("bd", new BigDecimal("1.0"));
+        map.put("bi", new BigInteger(Long.toString(Long.MAX_VALUE)));
+        map.put("bool", true);
+        map.put("map", new HashMap<>());
+        map.put("list", new ArrayList<>());
+        map.put("set", new HashSet<>());
+        map.put("smap", new TestSerializableMap());
+        map.put("slist", new TestSerializableList());
+        map.put("jv", JsonValue.EMPTY_MAP);
+        map.put("null", null);
+        map.put("jvNull", JsonValue.NULL);
+        map.put("empty_is_null", "");
+        validateMap(true, false, instance(map));
+    }
+
+    @Test
+    public void testValueUtilsMapBuilder() {
+        MapBuilder builder = mapBuilder()
+            .put("string", "Hello")
+            .put("char", 'c')
+            .put("int", 1)
+            .put("long", Long.MAX_VALUE)
+            .put("double", 1D)
+            .put("float", 1F)
+            .put("bd", new BigDecimal("1.0"))
+            .put("bi", new BigInteger(Long.toString(Long.MAX_VALUE)))
+            .put("bool", true)
+            .put("map", new HashMap<>())
+            .put("list", new ArrayList<>())
+            .put("set", new HashSet<>())
+            .put("smap", new TestSerializableMap())
+            .put("slist", new TestSerializableList())
+            .put("jv", JsonValue.EMPTY_MAP)
+            .put("null", null)
+            .put("jvNull", JsonValue.NULL)
+            .put("empty_is_null", "");
+        validateMap(false, false, builder.toJsonValue());
+        //noinspection deprecation
+        validateMap(false, false, builder.getJsonValue()); // coverage for deprecated
+        validateMap(false, true, JsonParser.parseUnchecked(builder.toJson()));
+    }
+
+    private static void validateMap(boolean checkNull, boolean parsed, JsonValue v) {
+        assertNotNull(v.map);
+        assertEquals(JsonValue.Type.STRING, v.map.get("string").type);
+        assertEquals(JsonValue.Type.STRING, v.map.get("char").type);
+        assertEquals(JsonValue.Type.INTEGER, v.map.get("int").type);
+        assertEquals(JsonValue.Type.LONG, v.map.get("long").type);
+        if (parsed) {
+            assertEquals(JsonValue.Type.BIG_DECIMAL, v.map.get("double").type);
+            assertEquals(JsonValue.Type.BIG_DECIMAL, v.map.get("float").type);
+            assertEquals(JsonValue.Type.LONG, v.map.get("bi").type);
+        }
+        else {
+            assertEquals(JsonValue.Type.DOUBLE, v.map.get("double").type);
+            assertEquals(JsonValue.Type.FLOAT, v.map.get("float").type);
+            assertEquals(JsonValue.Type.BIG_INTEGER, v.map.get("bi").type);
+        }
+        assertEquals(JsonValue.Type.BIG_DECIMAL, v.map.get("bd").type);
+        assertEquals(JsonValue.Type.BOOL, v.map.get("bool").type);
+        assertEquals(JsonValue.Type.MAP, v.map.get("map").type);
+        assertEquals(JsonValue.Type.ARRAY, v.map.get("list").type);
+        assertEquals(JsonValue.Type.ARRAY, v.map.get("set").type);
+        assertEquals(JsonValue.Type.MAP, v.map.get("smap").type);
+        assertEquals(JsonValue.Type.ARRAY, v.map.get("slist").type);
+        assertEquals(JsonValue.Type.MAP, v.map.get("jv").type);
+        if (checkNull) {
+            assertEquals(18, v.map.size());
+            assertEquals(JsonValue.Type.NULL, v.map.get("null").type);
+            assertEquals(JsonValue.Type.NULL, v.map.get("jvNull").type);
+            assertEquals(JsonValue.Type.NULL, v.map.get("empty_is_null").type);
+        }
+        else {
+            assertEquals(15, v.map.size());
+        }
+    }
+
+    @Test
+    public void testValueUtilsInstanceArray() {
+        List<Object> list = new ArrayList<>();
+        list.add("Hello");
+        list.add('c');
+        list.add(1);
+        list.add(Long.MAX_VALUE);
+        list.add(1D);
+        list.add(1F);
+        list.add(new BigDecimal("1.0"));
+        list.add(new BigInteger(Long.toString(Long.MAX_VALUE)));
+        list.add(true);
+        list.add(new HashMap<>());
+        list.add(new ArrayList<>());
+        list.add(new TestSerializableMap());
+        list.add(new TestSerializableList());
+        list.add(JsonValue.EMPTY_MAP);
+        list.add(null);
+        list.add(JsonValue.NULL);
+        validateArray(true, false, instance(list));
+    }
+
+    @Test
+    public void testValueUtilsArrayBuilder() {
+        ArrayBuilder builder = arrayBuilder()
+            .add("Hello")
+            .add('c')
+            .add(1)
+            .add(Long.MAX_VALUE)
+            .add(1D)
+            .add(1F)
+            .add(new BigDecimal("1.0"))
+            .add(new BigInteger(Long.toString(Long.MAX_VALUE)))
+            .add(true)
+            .add(new HashMap<>())
+            .add(new ArrayList<>())
+            .add(new TestSerializableMap())
+            .add(new TestSerializableList())
+            .add(JsonValue.EMPTY_MAP)
+            .add(null)
+            .add(JsonValue.NULL);
+        validateArray(false, false, builder.toJsonValue());
+        //noinspection deprecation
+        validateArray(false, false, builder.getJsonValue()); // coverage for deprecated
+        validateArray(false, true, JsonParser.parseUnchecked(builder.toJson()));
+    }
+
+    private static void validateArray(boolean checkNull, boolean parsed, JsonValue v) {
+        assertNotNull(v.array);
+        assertEquals(JsonValue.Type.STRING, v.array.get(0).type);
+        assertEquals(JsonValue.Type.STRING, v.array.get(1).type);
+        assertEquals(JsonValue.Type.INTEGER, v.array.get(2).type);
+        assertEquals(JsonValue.Type.LONG, v.array.get(3).type);
+        if (parsed) {
+            assertEquals(JsonValue.Type.BIG_DECIMAL, v.array.get(4).type);
+            assertEquals(JsonValue.Type.BIG_DECIMAL, v.array.get(5).type);
+            assertEquals(JsonValue.Type.LONG, v.array.get(7).type);
+        }
+        else {
+            assertEquals(JsonValue.Type.DOUBLE, v.array.get(4).type);
+            assertEquals(JsonValue.Type.FLOAT, v.array.get(5).type);
+            assertEquals(JsonValue.Type.BIG_INTEGER, v.array.get(7).type);
+        }
+        assertEquals(JsonValue.Type.BIG_DECIMAL, v.array.get(6).type);
+        assertEquals(JsonValue.Type.BOOL, v.array.get(8).type);
+        assertEquals(JsonValue.Type.MAP, v.array.get(9).type);
+        assertEquals(JsonValue.Type.ARRAY, v.array.get(10).type);
+        assertEquals(JsonValue.Type.MAP, v.array.get(11).type);
+        assertEquals(JsonValue.Type.ARRAY, v.array.get(12).type);
+        assertEquals(JsonValue.Type.MAP, v.array.get(13).type);
+        if (checkNull) {
+            assertEquals(16, v.array.size());
+            assertEquals(JsonValue.Type.NULL, v.array.get(14).type);
+            assertEquals(JsonValue.Type.NULL, v.array.get(15).type);
+        }
+        else {
+            assertEquals(14, v.array.size());
+        }
+    }
+
+    @Test
+    public void testReadStringStringMap() {
+        JsonValue jv = mapBuilder()
+            .put("stringString", mapBuilder().put("a", "A").put("b", "B").toJsonValue())
+            .put("empty", new HashMap<>())
+            .put("string", "string")
+            .toJsonValue();
+
+        assertNull(readStringStringMap(jv, "string"));
+        assertNull(readStringStringMap(jv, "empty"));
+        Map<String, String> stringString = readStringStringMap(jv, "stringString");
+        assertNotNull(stringString);
+        assertEquals(2, stringString.size());
+        assertEquals("A", stringString.get("a"));
+        assertEquals("B", stringString.get("b"));
+    }
+}
diff --git a/src/test/java/io/nats/client/support/JwtUtilsTests.java b/src/test/java/io/nats/client/support/JwtUtilsTests.java
index a33eb035a..32ca9114d 100644
--- a/src/test/java/io/nats/client/support/JwtUtilsTests.java
+++ b/src/test/java/io/nats/client/support/JwtUtilsTests.java
@@ -322,14 +322,11 @@ public void testUserClaimJson() {
         assertEquals(FULL_JSON, uc.toJson());
     }
 
-    @SuppressWarnings("deprecation")
     @Test
     public void testMiscCoverage() {
         long seconds = JwtUtils.currentTimeSeconds();
         sleep(1000);
         assertTrue(JwtUtils.currentTimeSeconds() > seconds);
-        // coverage, also makes sure that the deprecated JsonUtils.NATS_USER_JWT_FORMAT is pointed properly
-        assertEquals(io.nats.jwt.JwtUtils.NATS_USER_JWT_FORMAT, NATS_USER_JWT_FORMAT);
     }
 
     private static final String BASIC_JSON = "{\"issuer_account\":\"test-issuer-account\",\"type\":\"user\",\"version\":2,\"subs\":-1,\"data\":-1,\"payload\":-1}";
diff --git a/src/test/java/io/nats/service/ServiceTests.java b/src/test/java/io/nats/service/ServiceTests.java
index 0ff4b267c..b487817e7 100644
--- a/src/test/java/io/nats/service/ServiceTests.java
+++ b/src/test/java/io/nats/service/ServiceTests.java
@@ -18,7 +18,10 @@
 import io.nats.client.impl.JetStreamTestBase;
 import io.nats.client.impl.MockNatsConnection;
 import io.nats.client.impl.NatsMessage;
-import io.nats.client.support.*;
+import io.nats.client.support.DateTimeUtils;
+import io.nats.client.support.JsonSerializable;
+import io.nats.client.support.JsonUtils;
+import io.nats.client.support.JsonValue;
 import nl.jqno.equalsverifier.EqualsVerifier;
 import org.junit.jupiter.api.Test;
 
@@ -33,9 +36,9 @@
 import java.util.function.Supplier;
 
 import static io.nats.client.impl.NatsPackageScopeWorkarounds.getDispatchers;
+import static io.nats.client.support.JsonUtils.toKey;
 import static io.nats.client.support.JsonValueUtils.readInteger;
 import static io.nats.client.support.JsonValueUtils.readString;
-import static io.nats.client.support.JsonWriteUtils.toKey;
 import static io.nats.client.support.NatsConstants.DOT;
 import static io.nats.client.support.NatsConstants.EMPTY;
 import static io.nats.service.Service.SRV_PING;
@@ -728,7 +731,7 @@ public void testEndpointConstruction() {
             .build();
         assertEquals(NAME, e.getName());
         assertEquals(SUBJECT, e.getSubject());
-        assertTrue(Validator.mapEquals(metadata, e.getMetadata()));
+        assertTrue(JsonUtils.mapEquals(metadata, e.getMetadata()));
 
         // some subject testing
         e = new Endpoint(NAME, "foo.>");
@@ -740,7 +743,7 @@ public void testEndpointConstruction() {
         e = new Endpoint(NAME, SUBJECT, metadata);
         assertEquals(NAME, e.getName());
         assertEquals(SUBJECT, e.getSubject());
-        assertTrue(Validator.mapEquals(metadata, e.getMetadata()));
+        assertTrue(JsonUtils.mapEquals(metadata, e.getMetadata()));
         assertThrows(IllegalArgumentException.class, () -> Endpoint.builder().build());
 
         // many names are bad and is required
@@ -947,7 +950,7 @@ public void testServiceEndpointConstruction() {
             .endpointMetadata(metadata)
             .handler(smh)
             .build();
-        assertTrue(Validator.mapEquals(metadata, se.getMetadata()));
+        assertTrue(JsonUtils.mapEquals(metadata, se.getMetadata()));
 
         IllegalArgumentException iae = assertThrows(IllegalArgumentException.class,
             () -> ServiceEndpoint.builder().build());
@@ -1152,7 +1155,7 @@ public TestStatsData(JsonValue jv) {
 
         @Override
         public String toJson() {
-            return JsonWriteUtils.toKey(getClass()) + toJsonValue().toJson();
+            return JsonUtils.toKey(getClass()) + toJsonValue().toJson();
         }
 
         @Override