APIs for Advanced Security
Basic API security — hashing passwords, checking a bearer token — gets you through the first year of a project. This chapter covers what shows up once an API has real operational maturity behind it: rotating signing keys without breaking already-issued tokens, mutual TLS for service-to-service trust, HMAC request signing for machine clients, and treating secrets as something that's managed, not just configured.
JWT signing key rotation
A JWT signed with a single, permanent key has a quiet failure mode: the moment that key needs to change (a suspected leak, a scheduled rotation policy), every token in circulation becomes invalid at once, unless you plan for rotation from the start. The fix is a key ID (kid) embedded in the token header, pointing at which key signed it, so multiple keys can be valid simultaneously during a rotation window:
package main
import (
"crypto/rsa"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
var signingKeys = map[string]*rsa.PrivateKey{
"2026-07": currentKey,
"2026-04": previousKey, // still valid for tokens issued before rotation
}
func issueToken(userID, activeKeyID string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"sub": userID,
"exp": time.Now().Add(15 * time.Minute).Unix(),
})
token.Header["kid"] = activeKeyID
return token.SignedString(signingKeys[activeKeyID])
}
func verifyToken(tokenString string) (*jwt.Token, error) {
return jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
kid, ok := t.Header["kid"].(string)
if !ok {
return nil, fmt.Errorf("missing kid")
}
key, ok := signingKeys[kid]
if !ok {
return nil, fmt.Errorf("unknown key: %s", kid)
}
return &key.PublicKey, nil
})
}
Keeping the previous key around lets tokens issued right before a rotation keep verifying until they naturally expire, while all new tokens use the new key — you retire the old key entirely only once you're confident nothing valid still references it. In systems with external consumers, this same set of public keys is what you'd expose at a /.well-known/jwks.json endpoint, in the JSON Web Key Set format, so other services can verify your tokens without you distributing keys out of band.
RS256/ES256) over HS256 for tokens verified outside your own service.Mutual TLS between services
Ordinary TLS proves the server's identity to the client. Mutual TLS (mTLS) adds the reverse: the server also verifies a certificate presented by the client, which is the standard way internal services authenticate each other without passing bearer tokens around. Chapter 3.19 covers the base TLS handshake and certificate verification this builds on; Chapter 4.9 applies the same mechanism to zero trust service-to-service authentication. Go's crypto/tls supports this natively on both ends:
import (
"crypto/tls"
"crypto/x509"
"net/http"
"os"
)
func newMTLSServer(certFile, keyFile, clientCAFile string) *http.Server {
clientCAPool := x509.NewCertPool()
caCert, _ := os.ReadFile(clientCAFile)
clientCAPool.AppendCertsFromPEM(caCert)
tlsConfig := &tls.Config{
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: clientCAPool,
}
server := &http.Server{
Addr: ":8443",
Handler: setupRouter(),
TLSConfig: tlsConfig,
}
return server // caller runs server.ListenAndServeTLS(certFile, keyFile)
}
tls.RequireAndVerifyClientCert rejects any connection where the client doesn't present a certificate signed by a CA in ClientCAs — the TLS handshake itself fails before your handler code ever runs, which is a meaningfully stronger guarantee than checking a header after the fact. Extracting the verified client identity inside a handler is just as direct:
func handler(w http.ResponseWriter, r *http.Request) {
if len(r.TLS.PeerCertificates) == 0 {
http.Error(w, "no client certificate", http.StatusUnauthorized)
return
}
clientCN := r.TLS.PeerCertificates[0].Subject.CommonName
// clientCN now identifies which service is calling,
// cryptographically verified
}
HMAC request signing for machine clients
For API clients that are other services or third-party integrations rather than end users, HMAC request signing (the same primitive from the webhooks chapter, applied to outbound API calls instead of inbound events) avoids handing out long-lived bearer tokens that can leak in logs:
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"time"
)
func signRequest(req *http.Request, secret, body []byte) {
timestamp := fmt.Sprintf("%d", time.Now().Unix())
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(timestamp))
mac.Write(body)
signature := hex.EncodeToString(mac.Sum(nil))
req.Header.Set("X-Timestamp", timestamp)
req.Header.Set("X-Signature", signature)
}
Including the timestamp in the signed payload lets the receiver reject requests outside a short acceptable window (say, five minutes), which closes off replay attacks where a captured request is resent verbatim later — a bare HMAC signature over the body alone doesn't protect against that.
Secrets management
None of the above matters if the private keys and HMAC secrets themselves live in a .env file committed to source control. Production systems fetch secrets at startup from a dedicated store — HashiCorp Vault, AWS Secrets Manager, or a cloud provider's equivalent — rather than reading them from environment variables set by a deploy script:
import (
"context"
"crypto/rsa"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
"github.com/golang-jwt/jwt/v5"
)
func loadSigningKey(
ctx context.Context, client *secretsmanager.Client, secretID string,
) (*rsa.PrivateKey, error) {
out, err := client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
SecretId: aws.String(secretID),
})
if err != nil {
return nil, err
}
return jwt.ParseRSAPrivateKeyFromPEM([]byte(*out.SecretString))
}
Revocation: the problem signing keys alone don't solve
Rotating a signing key stops new tokens from being forged with a compromised one, but it does nothing about tokens already issued and still within their expiry window — if a token is stolen, key rotation alone won't invalidate it before it naturally expires. For access that needs to be revocable immediately (a user reports a stolen session, an employee is offboarded), keep short expiries on the token itself and maintain a denylist checked on verification for the rare case that a specific token needs killing before it would otherwise expire:
import (
"context"
"time"
"github.com/redis/go-redis/v9"
)
func isRevoked(ctx context.Context, rdb *redis.Client, tokenID string) bool {
exists, _ := rdb.Exists(ctx, "revoked:"+tokenID).Result()
return exists > 0
}
func revokeToken(
ctx context.Context, rdb *redis.Client, tokenID string, ttl time.Duration,
) error {
return rdb.Set(ctx, "revoked:"+tokenID, "1", ttl).Err()
}
Setting the denylist entry's TTL to match the token's remaining lifetime keeps the denylist small — there's no reason to remember a revoked token past the point it would have expired anyway.
Advanced API security is rarely about a cleverer algorithm — it's about making the boring parts (rotation, expiry, revocation, and where the secret actually lives) impossible to skip.
Frequently Asked Questions
Why embed a kid in the JWT header instead of just rotating the signing key outright?
Because a hard cutover invalidates every token already in circulation the instant you rotate, which is exactly the quiet failure mode the chapter opens with. Keeping both "2026-07" and "2026-04" valid in the signingKeys map lets tokens issued right before rotation keep verifying until they naturally expire, so you retire the old key only once nothing valid still references it — rotation becomes a window, not an instant break.
If HMAC (HS256) and RSA (RS256) both sign tokens, why does the chapter push toward RSA for anything verified outside your own service?
Because of how rotation and distribution behave, not the cryptographic strength — a public key can be published freely for others to verify against, but a shared HMAC secret has to reach every verifying party before rotation completes, and there's no safe way to publish it to third parties at all. That asymmetry is why RS256/ES256 are recommended for tokens anyone outside your own service needs to check.
Does mTLS replace the need for bearer tokens or API keys between services?
For service-to-service authentication, yes — that's the point of tls.RequireAndVerifyClientCert: the TLS handshake itself fails before your handler code ever runs if the client doesn't present a certificate signed by a trusted CA, which is a stronger guarantee than checking a header after the fact. You still read r.TLS.PeerCertificates[0].Subject.CommonName to know which service called you, but there's no separate token to issue, store, or leak in logs.
Why does signRequest include a timestamp in the HMAC payload instead of just signing the body?
Because a signature over the body alone doesn't stop someone from capturing a valid signed request and resending it verbatim later — a replay attack. Including the timestamp lets the receiver reject anything outside a short acceptable window, say five minutes, which closes off exactly that gap without changing what's actually being signed.
If I've already rotated my signing key, why do I also need a revocation denylist?
Because rotation and revocation solve different problems — rotating stops new tokens from being forged with a compromised key, but it does nothing about a token that was already issued and is still inside its expiry window. For cases that need to be killed immediately, like an offboarded employee or a reported stolen session, isRevoked checked against a denylist is what actually invalidates a specific token before it would otherwise expire on its own.