VaultToken is designed to defend against the following attack classes.
What: Attacker sets alg: "none" in the JWT header and removes the signature. Libraries that trusted the header's algorithm field would accept unsigned tokens.
JWT status: Several major libraries were historically vulnerable (pre-2015).
VaultToken defense: PASETO tokens have no algorithm field. The version prefix (v4.local. or v4.public.) is compared against a hardcoded constant in your verification code — not a value from the token. There is no code path that accepts v4.none..
What: Server expects RS256. Attacker takes the server's public key (which is public by design), uses it as the HMAC secret, and signs a forged token with HS256. Vulnerable libraries verify it as valid because the public key bytes match the HMAC input.
JWT status: Real and exploited. Required careful library-level mitigations.
VaultToken defense: There is no algorithm negotiation in PASETO. v4.public means Ed25519, always. The key type is enforced by how you call the library — AsymmetricSecretKey for signing, AsymmetricPublicKey for verification. There is no API surface for confusion.
What: JWT payloads are Base64URL encoded, not encrypted. Anyone who sees the token — in logs, browser history, XSS output, HTTP access logs — can decode the payload and read all claims including roles, user IDs, and sensitive metadata.
JWT status: By design. JWE is required for encryption, but rarely used.
VaultToken defense: v4.local encrypts the payload with XChaCha20-Poly1305 (256-bit AEAD). The ciphertext reveals nothing without the symmetric key. Even the token length leaks minimal information.
What: HS256 allows password-derived secrets. Developers use "secret", "password", or short env vars. An attacker captures any valid token and runs hashcat offline — no server contact required.
JWT status: Very common in practice. Tools like jwt_tool and hashcat handle this automatically.
VaultToken defense: v4.local keys are 256-bit uniformly random — no API accepts a password. v4.public keys are Ed25519 keypairs generated by the library. There is no way to accidentally use a weak key.
What: JWT's kid (Key ID) header is sometimes used to look up keys from a database or file. Attackers inject SQL (' OR 1=1--) or path traversal (../../etc/passwd) into this field.
JWT status: Exploited in several real systems.
VaultToken defense: The footer kid field is validated against a strict regex before use (/^key-v4[lp]-[0-9A-Z]{26}$/). No dynamic SQL or filesystem lookups from key IDs. Additionally, the footer is PAE-bound — any tampering with the footer bytes causes the AEAD tag to fail.
What: A valid token captured in transit (MITM, compromised log, XSS) is replayed to impersonate the original user.
JWT status: Only mitigated by short exp values — no built-in replay prevention.
VaultToken defense (layered):
- Short
exp(default 1 hour access tokens) - JTI revocation on logout
- Implicit assertions (bind token to IP + User-Agent — stolen token fails from different context)
- Token family tracking for refresh tokens
What: A refresh token is intercepted. Attacker uses it to silently obtain new access tokens indefinitely, even after the victim logs out.
JWT status: No standard mechanism for detection.
VaultToken defense — Token Family Tracking:
- Every refresh token belongs to a family
- A refresh token is consumed on use (rotation-on-use)
- If the same refresh token is presented twice, the server knows one copy is stolen
- The entire family is immediately revoked — both attacker and victim lose access
- Victim is forced to re-authenticate, removing the attacker's foothold
What: Measure how long the server takes to reject API keys. Shorter rejection time for short mismatches can leak key length or prefix bytes.
VaultToken defense: API key comparison uses crypto.timingSafeEqual() — constant time regardless of where the mismatch occurs. Additionally, random per-request jitter (0–2ms) is added to verification responses.
What: Attacker gains read access to Redis (exposed port, misconfiguration) and extracts raw key material.
VaultToken defense: All symmetric and private key material is encrypted before writing to Redis using AES-256-GCM with a separate KEY_ENCRYPTION_SECRET. Redis only contains ciphertext. Without KEY_ENCRYPTION_SECRET, the Redis dump is useless.
What: Token issued for tenant A is presented to tenant B's service and accepted.
VaultToken defense: Each tenant has a completely isolated key namespace in Redis. A token encrypted with tenant A's key will always fail the AEAD MAC check when verified under tenant B's key. This is structurally impossible to bypass — it is enforced by the cryptographic primitive, not application logic.
Algorithm : XChaCha20-Poly1305 (IETF)
Key size : 256 bits (32 bytes), uniformly random
Nonce size: 192 bits (24 bytes), randomly generated per token
Tag size : 128 bits (16 bytes)
Security : IND-CCA2 — ciphertext indistinguishable from random
Additional: PAE(header, nonce, footer, implicitAssertion)
Why XChaCha20 over AES-CBC or AES-GCM?
- No padding oracle risk (stream cipher — no padding)
- Constant-time — not vulnerable to cache-timing attacks on AES S-box
- 192-bit nonce — collision probability negligible even with random nonce generation
- Used in TLS 1.3, WireGuard, Signal Protocol — extensively audited
- 64-bit nonce advantage over ChaCha20 — safer with large volumes of tokens
Algorithm : EdDSA over Curve25519 (Ed25519)
Secret key : 32-byte seed (private scalar)
Public key : 32 bytes
Signature : 64 bytes
Security : ~128-bit (equivalent) against best known attacks
Deterministic: Yes — no random nonce in signing, no nonce-reuse risk
Why Ed25519 over RSA-2048 or ECDSA P-256?
- Deterministic — ECDSA with a repeated nonce leaks the private key (happened to Sony PlayStation)
- Shorter keys and signatures than RSA — no performance tradeoff
- Faster than RSA-2048 signing and ECDSA P-256 on modern hardware
- Resistant to fault attacks — determinism means no timing variability to exploit
- Twist-secure curve — resistant to invalid-curve attacks
encrypt(rawKey):
iv = randomBytes(12)
cipher = AES-256-GCM(KEY_ENCRYPTION_SECRET, iv)
ciphertext = cipher.encrypt(JSON.stringify(keyRecord))
tag = cipher.authTag()
stored = base64(iv || tag || ciphertext)
decrypt(stored):
buf = base64decode(stored)
iv = buf[0:12]
tag = buf[12:28]
ciphertext = buf[28:]
decipher = AES-256-GCM(KEY_ENCRYPTION_SECRET, iv)
decipher.setAuthTag(tag)
keyRecord = JSON.parse(decipher.decrypt(ciphertext))
KEY_ENCRYPTION_SECRET requirements:
- 32 bytes (256-bit), uniformly random
- Never stored alongside the encrypted keys
- Managed via environment variable — injected by secrets manager in production
- Rotated separately from PASETO keys on a different schedule
- Raw symmetric key bytes
- Ed25519 private key seed
- KEY_ENCRYPTION_SECRET
- Full token values
- API keys (stored as HMAC-SHA256 hashes)
Every response includes:
Cache-Control: no-store
Pragma: no-cache
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Content-Security-Policy: default-src 'none'
Referrer-Policy: no-referrer
Cache-Control: no-store ensures tokens are never cached by proxies or browsers.
Do not open a public GitHub issue for security vulnerabilities.
Email: security@vaulttoken.dev
Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Your suggested fix (optional)
We aim to respond within 48 hours and patch within 7 days for critical issues.