The choice in one paragraph
HS256 uses a shared secret. Whoever holds the secret can both sign and verify. Same key both ways.
RS256 uses an asymmetric key pair. The private key signs; the public key verifies. Different keys; verifiers don’t need the secret.
When each is correct
HS256 is right when the same process (or trust boundary) both issues and verifies. A single Go service that mints tokens for its own consumption and consumes them on subsequent requests. The secret lives in one place; rotation is a one-knob operation.
RS256 is right when verifiers are separate from issuers. A central IdP signs tokens; many downstream services verify them. The downstreams hold only the public key; even a compromised downstream can’t mint forgeries.
The classic mistake
Some teams reach for RS256 by default “because asymmetric is more secure.” For a single-issuer-single-verifier system this buys nothing and costs:
- Key management complexity (private key handling, secure storage, rotation).
- Slower verify (RSA is ~100× slower than HMAC).
- A bigger attack surface in the signing library.
- JWKS publication infrastructure for key rotation.
If you’re not splitting issue from verify, HS256 is the simpler choice.
What changes if the key leaks
HS256 leak. Every token ever issued or issued during the leak window is forgeable. The mitigation is rotation: change the secret, invalidate all in-flight tokens, force re-authentication.
RS256 private-key leak. Same as HS256 — forgeable. The public-key-only leak doesn’t matter (it’s already public).
The blast radius is identical; the difference is who holds the key. Asymmetric reduces who might leak, not what happens when leak occurs.
What this looks like in Go
// HS256 (Genie default)
issuer := auth.NewIssuer(secret, "genie-api", []string{"genie-api"}, 60*time.Minute)
token, _, _ := issuer.Issue(userID, email, roles)
claims, _ := issuer.Verify(token)
// RS256 (federated deployment)
priv, _ := loadPrivateKey("genie.key")
pub, _ := loadPublicKey("genie.pub")
issuer := auth.NewRSAIssuer(priv, "genie-api", aud, ttl)
verifier := auth.NewRSAVerifier(pub, "genie-api", aud)
For Genie’s reference single-issuer topology, HS256 wins. When a federated deployment lands (bank issues SAML/OIDC token, Genie consumes), RS256 lives in the verifier slot — pkg/auth is designed to swap.
The boring rule: pick HS256 unless you actually have multiple verification boundaries. Asymmetric crypto for its own sake is the wrong default.