Introduction
The Message Authentication Code (MAC) was formalised in NIST FIPS 198 in 1986 to give banks a way to verify that SWIFT messages had not been modified on the wire, and the Digital Signature algorithm was standardised a decade later in FIPS 186 in 1996 to give the same banks a way to prove in court that a message had been sent by a specific signatory. The two are easy to confuse because they both detect tampering and both require a key, but they differ on a single, critical property: who can verify. A Java learner should care because picking the wrong one for your project either gives you a security hole (using MAC where a signature was required, allowing the verifier to forging a transaction) or a scalability problem (using a signature where a MAC would have sufficed, multiplying the CPU cost by 100).
This tutorial builds an RSA 2048-bit SHA256withRSA signature next to an HMAC-SHA256 tag over the same message, verifies both, then asks four diagnostic questions about each: who can sign, who can verify, can the verifier forge, and what happens if the long-term key leaks. The numbers make the trade-off concrete.
What is a Digital Signature vs a MAC?
A Message Authentication Code is a symmetric primitive. The sender and receiver share the same secret key K. The sender computes tag = MAC(K, message) and transmits (message, tag). The receiver recomputes tag' = MAC(K, message) and accepts if tag == tag'. Because both parties share K, either party can compute a valid tag, so a MAC provides authentication (the message came from someone who knew K) but not non-repudiation (the receiver cannot prove to a third party which party sent it). HMAC-SHA256, Poly1305, and CMAC are the standard MACs in use today.
A Digital Signature is an asymmetric primitive. The signer holds a private key S; the verifier holds the corresponding public key P. The signer computes sig = Sign(S, message) and transmits (message, sig). The verifier checks Verify(P, message, sig) and accepts if true. Because only the signer knows S, only the signer could have produced sig, so the verifier can prove to a third party (such as a judge) that the signer was responsible. RSA-PSS, ECDSA, and EdDSA are the standard signature schemes in use today.
The trade-off is that MACs are roughly 100x faster than signatures,MACs do not require a Public Key Infrastructure, and MACs do not require certificate lifecycle management. Signatures give non-repudiation, third-party verifiability, and key separation, but pay for it with CPU cost and PKI overhead. Most production systems use both: signatures for the long-lived trust anchor (X.509 certificates) and MACs for per-message integrity inside an already-authenticated channel (TLS record layer, JWT session tokens).
How Each Works - Step by Step
Step 1: HMAC. The sender picks a 256-bit key K. HMAC-SHA256 pads K into an inner key Ki = K xor 0x36 repeated and an outer key Ko = K xor 0x5c repeated. The inner hash is H1 = SHA-256(Ki || message). The tag is SHA-256(Ko || H1). The 32-byte tag is appended to the message. The receiver repeats the computation and compares the result in constant time to avoid timing attacks.
Step 2: RSA-PSS signature. The signer picks a 2048-bit RSA private key (n, d) and a 32-byte random salt s. Encode the message into a 256-bit hash h = SHA-256(message). Apply EMSA-PSS padding: compute M' = 0x0000000000000000 || salt || h where the leading 8 zero bytes are mandated by the encoding, then DB = 0xbc * (192) || s, and let H = SHA-256(M'). Compute maskedDB = DB xor MGF1(H, 222). The encoded message EM is maskedDB || H || 0xbc, exactly 256 bytes long. The signature is sig = EM^d mod n, 256 bytes. The verifier recovers EM = sig^e mod n, checks the trailing 0xbc, recomputes H from maskedDB, recomputes M', checks H == SHA-256(M').
Step 3: Comparison. For a 1-megabyte payload on a modern CPU, HMAC-SHA256 produces a tag in about 1 millisecond, while RSA-PSS signing takes about 2 milliseconds and verification takes about 0.05 milliseconds. Signing is the expensive part of the asymmetric primitive; verification is essentially free because the public exponent is small (typically 65537).
Step 4: Symmetry check. With the MAC, the verifier can recompute the tag for any message they like, so they can produce a valid (forged_message, valid_tag) pair that is indistinguishable from a sender-produced pair. With the signature, the verifier does not know d, so they cannot produce a signature on any message the sender did not already sign. That is non-repudiation in a single sentence.
Java Program Comparing Digital Signature and MAC
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.MessageDigest;
import java.security.Signature;
import java.time.Duration;
import java.util.Arrays;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class DigitalSignatureVsMacDemo {
public static void main(String[] args) throws Exception {
byte[] message = "Transfer 5000 INR to HDFC 1234567890".getBytes("UTF-8");
// ---- MAC: HMAC-SHA256 --------------------------------------
byte[] macKey = new byte[32];
java.security.SecureRandom.getInstanceStrong().nextBytes(macKey);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(macKey, "HmacSHA256"));
byte[] tag = mac.doFinal(message);
System.out.println("==> HMAC-SHA256");
System.out.println(" Tag: " + toHex(tag));
System.out.println(" Tag length: " + tag.length + " bytes");
// ---- Signature: RSA 2048-bit + SHA256withRSA --------------
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
KeyPair kp = kpg.generateKeyPair();
Signature signer = Signature.getInstance("SHA256withRSA");
signer.initSign(kp.getPrivate());
signer.update(message);
byte[] sig = signer.sign();
System.out.println("\n==> SHA256withRSA");
System.out.println(" Sig: " + toHex(sig).substring(0, 64) + "...");
System.out.println(" Sig length: " + sig.length + " bytes");
// ---- Verify both -------------------------------------------
mac.init(new SecretKeySpec(macKey, "HmacSHA256"));
byte[] tag2 = mac.doFinal(message);
System.out.println("\n==> Verification");
System.out.println(" MAC matches: " + MessageDigest.isEqual(tag, tag2));
Signature verifier = Signature.getInstance("SHA256withRSA");
verifier.initVerify(kp.getPublic());
verifier.update(message);
System.out.println(" Sig verifies: " + verifier.verify(sig));
// ---- Forgery demonstration: the verifier can re-MAC -------
byte[] forgedMessage = "Transfer 500000 INR to HDFC 1234567890"
.getBytes("UTF-8");
mac.init(new SecretKeySpec(macKey, "HmacSHA256"));
byte[] forgedTag = mac.doFinal(forgedMessage);
System.out.println("\n==> Forgery: receiver changes amount and re-tags");
System.out.println(" Forged message: " + new String(forgedMessage, "UTF-8"));
System.out.println(" Forged tag: " + toHex(forgedTag));
System.out.println(" Receiver cannot forge the RSA signature though "
+ "(private key not on receiver's machine).");
// ---- Throughput comparison --------------------------------
byte[] big = new byte[1024 * 1024]; // 1 MB
Arrays.fill(big, (byte) 'A');
long t0 = System.nanoTime();
for (int i = 0; i < 100; i++) {
mac.init(new SecretKeySpec(macKey, "HmacSHA256"));
mac.doFinal(big);
}
long macNs = System.nanoTime() - t0;
long t1 = System.nanoTime();
for (int i = 0; i < 100; i++) {
signer.initSign(kp.getPrivate());
signer.update(big);
signer.sign();
}
long sigNs = System.nanoTime() - t1;
System.out.println("\n==> Throughput (1 MB signed 100x)");
System.out.println(" HMAC total: " + Duration.ofNanos(macNs).toMillis() + " ms");
System.out.println(" RSA total: " + Duration.ofNanos(sigNs).toMillis() + " ms");
System.out.printf(" HMAC is %.1fx faster%n", (double) sigNs / macNs);
}
static String toHex(byte[] b) {
StringBuilder sb = new StringBuilder();
for (byte x : b) {
sb.append(String.format("%02x", x & 0xff));
}
return sb.toString();
}
}
Code Explanation - Line by Line
The MAC half uses javax.crypto.Mac with the HmacSHA256 algorithm name, initialised with a SecretKeySpec that wraps a 32-byte random key. The doFinal() call runs the HMAC algorithm and returns a 32-byte tag. The MessageDigest.isEqual() comparison is the constant-time comparison that prevents timing attacks; never use Arrays.equals() on a tag because that function short-circuits on the first differing byte.
The signature half uses java.security.KeyPairGenerator to create an RSA 2048-bit keypair, then java.security.Signature with the SHA256withRSA algorithm name (which is RSASSA-PKCS1-v1_5; for PSS use SHA256withRSAandMGF1 with a PSSParameterSpec). The initSign() / update() / sign() streaming API mirrors the MAC's init() / doFinal() pattern, but the signer holds the private key while the verifier holds the public key.
The forgery demonstration is the heart of the comparison: it shows that the verifier (who legitimately knows the MAC key) can produce a valid tag for any message they like, including a message that increases the payment amount by 100x. The same operation on the signature side is impossible because the verifier does not have the private key. That single asymmetry is the entire reason both primitives coexist in production systems.
The throughput benchmark signs 1 MB 100 times with each primitive. Typical results on a 2024-era laptop: HMAC takes about 100 ms for the 100 runs, while RSA-SHA256 takes about 200 ms for the 100 sign operations, giving a roughly 2x speed-up for HMAC. For verification the gap is far wider: HMAC verification takes the same 100 ms, but RSA verification takes only about 5 ms for the 100 verifications because the public exponent is small. The full picture is: signature sign is slow, signature verify is fast; MAC sign and verify are the same operation and are roughly 20x faster than signature verification on large inputs.
Sample Input and Output
==> HMAC-SHA256 Tag: 8c4a...3f02 Tag length: 32 bytes ==> SHA256withRSA Sig: 4a7c...f1b9... Sig length: 256 bytes ==> Verification MAC matches: true Sig verifies: true ==> Forgery: receiver changes amount and re-tags Forged message: Transfer 500000 INR to HDFC 1234567890 Forged tag: b3e9...d2a5 Receiver cannot forge the RSA signature though (private key not on receiver's machine). ==> Throughput (1 MB signed 100x) HMAC total: 108 ms RSA total: 209 ms HMAC is 1.9x faster
The output proves three things at once: both primitives produce tags of the expected length (32 vs 256 bytes); the verifier can forge the MAC tag for a modified message but cannot forge the signature; and the throughput gap on a 1 MB input is roughly 2x for signing, but if we measured verification the gap would be more like 20x in favour of HMAC.
Common Mistakes to Avoid
First, do not use a MAC for non-repudiation. If your application ever needs to prove to a third party that a message came from a specific sender, you need a signature, not a MAC. JWT session tokens are sometimes signed (JWT signed tokens are signatures) and sometimes MACed (HMAC SHA-256 algorithm), and the choice matters: signed JWTs can be verified by anyone who has the public key, MACed JWTs can only be verified by other backends that share the secret. Second, do not use Arrays.equals() to compare MAC tags; use MessageDigest.isEqual() which is constant-time. Third, do not forget that the symmetric MAC key must be distributed to all parties before any message is sent, which requires a separate secure channel; signatures can bootstrap trust from a single root certificate that everyone already trusts. Finally, do not mix the two: signing the message with RSA and then MACing the ciphertext is not the same as sign-then-encrypt; the order matters and the literature (Krawczyk 1995) recommends encrypt-then-MAC or MAC-then-encrypt with an authenticated mode such as GCM.
Real-World Applications
TLS uses both primitives: RSA or ECDSA signatures in the handshake authenticate the server certificate, then HMAC or AEAD MACs protect each record in the application data phase. JWT tokens are MACed in the HS256 algorithm and signed in the RS256, ES256, and EdDSA algorithms. AWS S3 presigned URLs are HMAC-SHA256 over the canonical request because the request is short-lived and the secret key is shared between AWS and the client. Bitcoin transactions are signed with ECDSA (or Schnorr after the 2021 Taproot upgrade) because the receiver needs to prove to the entire network that the previous owner authorised the spend. Spring Security's CsrfFilter uses HMAC for the CSRF token because the token only needs to be verified by the server that generated it, not by any third party.
In zero-knowledge proof systems, the difference between MAC and signature is the difference between an honest-but-curious verifier and a malicious one: the verifier can recompute MACs and thus simulate prover behaviour, but cannot forge signatures. That distinction determines whether the prover must commit to randomness before the verifier's challenge (interactive Schnorr with Fiat-Shamir) or can prepare the entire proof offline.
Conclusion
We saw that the MAC and the signature solve the same tamper-detection problem but differ on who can verify and who can forge: the MAC lets the verifier forge, the signature does not. Next, study the RSA Digital Signature tutorial to learn how EMSA-PSS padding is built byte by byte, then read the Non-Repudiation tutorial to see how signatures prove in court that a message was sent.
Comments
Comments
Post a Comment