dsh-tool-jwt
DSH JWT tool plugin — HS256 JWT decode (without verification), sign, and full verification. Pure functions, zero runtime dependencies (only Node.js built-in crypto).

Motivation
Backend developers deal with JWTs daily: you want to peek inside a token to see what's in it and whether it's expired, but manually base64url-decoding + JSON-parsing + timestamp arithmetic is too slow; during integration testing you need to craft test tokens (for gateways/downstream auth), and hand-assembling header/payload/signature is error-prone; when verifying a token's signature and expiry, mental HMAC is unreliable.
This plugin provides deterministic JWT tools, of which decode is the differentiating capability: it does not verify the signature, only does structural parsing + expiry status computation (against the current clock), for quick token inspection. verify runs the full validation chain: algorithm check (alg=HS256), constant-time signature comparison (crypto.timingSafeEqual, resistant to timing attacks), and expiry judgment (optional leewaySeconds clock-skew tolerance).
Security Model
JWT signature forgery and algorithm confusion are real threats. Defenses:
1. Algorithm enforcement: verify only accepts alg=HS256; alg=none or any non-HS256 algorithm is rejected outright, preventing algorithm downgrade attacks 2. Constant-time signature comparison: crypto.timingSafeEqual, byte-by-byte comparison that leaks no timing information 3. Input length caps: token ≤ 16KB, secret ≤ 4KB, serialized payload ≤ 8KB — over-limit inputs are rejected at the entry point and never enter processing 4. decode does not verify: only structural parsing + expiry status computation, explicitly informing the caller that "decode does not verify the signature"
> ⚠️ This tool is intended for development/integration-testing scenarios (inspecting tokens, crafting test tokens, verifying signatures). Never paste production secrets into untrusted sessions.
Other boundaries: invalid base64url strings are caught and reported (with input truncation); non-JSON header/payload is reported (with location); non-object JSON is reported; non-numeric exp is reported; mutually-exclusive secret detection (secret and secretBase64url given together is rejected).
Tool Declaration
Registers the jwt tool (dsh-tool-jwt, row id tool-jwt), uniformly outputting a text report.
| Parameter | Type | Required | Description |
|---|---|---|---|
action | string | ✅ | decode / sign / verify |
token | string | JWT compact serialization (header.payload.signature). Required for decode/verify | |
payload | object | Claim object (required for sign), e.g. {"sub":"u1","role":"admin"}. exp/iat are managed by expiresInSeconds | |
secret | string | HMAC secret (UTF-8 bytes). Required for sign/verify. Mutually exclusive with secretBase64url | |
secretBase64url | string | HMAC secret (base64url-encoded raw bytes, for binary keys such as RFC 7515 vectors and JWK k values). Mutually exclusive with secret | |
expiresInSeconds | integer | sign only: validity duration in seconds; sets exp = now + N (skipped when payload.exp is given) | |
leewaySeconds | integer | verify only: allowed clock skew in seconds when judging exp (default 0) |
Actions
| action | Function | Output example |
|---|---|---|
decode | Parse header/payload as JSON, compute expiry status against the current clock (valid / expired / no-exp-claim, with ISO timestamps and remaining/elapsed seconds). Does not verify the signature | header: {"alg":"HS256","typ":"JWT"}<br>payload: {"sub":"u1","exp":1767231600}<br>expiry.status: valid<br>expiry.expiresInSeconds: 3600 |
sign | Issue an HS256 token. Optional expiresInSeconds auto-sets exp, iat is auto-added; an explicit payload.exp takes precedence | token: eyJ...<br>claims: {"sub":"u1","iat":1767225600,"exp":1767231600} |
verify | Full validation: alg=HS256, constant-time signature comparison, expiry judgment (optional leewaySeconds) | valid: true<br>payload: {"sub":"u1","exp":1767231600}<br>expiry.expiresAt: 2026-01-01T01:00:00.000Z |
Examples
jwt { action: "decode", token: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSJ9.sig" }
→ header: {"alg":"HS256","typ":"JWT"}
payload: {"sub":"u1"}
expiry.status: no-exp-claim
note: decode does not verify the signature
jwt { action: "sign", payload: {"sub":"u1","role":"admin"}, secret: "topsecret", expiresInSeconds: 3600 }
→ token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc2NzIyNTYwMCwiZXhwIjoxNzY3MjI5MjAwfQ.<sig>
claims: {"sub":"u1","role":"admin","iat":1767225600,"exp":1767229200}
jwt { action: "verify", token: "eyJ...", secret: "topsecret", leewaySeconds: 30 }
→ valid: true
payload: {"sub":"u1","role":"admin","iat":1767225600,"exp":1767229200}
expiry.expiresAt: 2026-01-01T01:00:00.000ZEdge Cases
| Case | Handling |
|---|---|
| Token without three segments | jwt: token must have exactly three segments |
| Non-JSON header/payload | jwt: header/payload is not valid JSON |
| Non-object header/payload | jwt: header/payload JSON must be an object |
| Invalid base64url | jwt: invalid base64url input: "..." |
exp not a number | jwt: exp claim must be a number |
exp exactly equal to current time | expired (RFC 7519: exp must be strictly greater than the current time) |
No exp claim | no-exp-claim |
| Token over 16KB / secret over 4KB / payload over 8KB | Rejected at the entry point (not truncated) |
Both secret and secretBase64url given | Rejected at the entry point |
| Signature mismatch | valid: false + reason: bad-signature |
alg not HS256 | valid: false + reason: wrong-alg |
| Expired token | valid: false + reason: expired (tolerated within leewaySeconds) |
| Empty secret | jwt: secret must be a non-empty string |
| RFC 7515 binary keys | Passed via secretBase64url as raw bytes (Buffer.from(key, 'base64url')) |
Correctness
The signature logic is verified against the RFC 7515 Appendix A.1 official HS256 test vector — the verifyJwt: RFC vector test group in tests/jwt-sign.spec.ts confirms byte-for-byte agreement with the RFC standard output.
Installation
Profile Bundle (Recommended)
Install this plugin into a profile as a standalone bundle:
# Interactive (web) profile —— install from the GitHub repository
dsh plugin --profile web add github:chenxuhl/dsh-tool-jwt
# One-off task (headless) profile —— dsh run uses headless by default
dsh plugin --profile headless add github:chenxuhl/dsh-tool-jwtOr install from the tarball produced by npm pack:
npm pack # produces dsh-tool-jwt-<version>.tgz
# Interactive (web) profile
dsh plugin --profile web add ./dsh-tool-jwt-<version>.tgz
# One-off task (headless) profile
dsh plugin --profile headless add ./dsh-tool-jwt-<version>.tgzThe bundled dsh.bundle.patch automatically adds the plugin to the profile's layer stack after installation (row id: tool-jwt). The plugin's missing peer dependencies (@deepseek-ai/cordis, @deepseek-ai/dsh-tools) are provided by the profile's healed profiles/node_modules fallback installation.
> ⚠️ web and headless are different profiles: installing into web does not automatically cover headless; dsh run uses the headless profile by default. Use forward slashes for Windows paths (C:/...).
Local Development (link installation)
git clone https://github.com/chenxuhl/dsh-tool-jwt.git
cd dsh-tool-jwt && npm install && npm run build
dsh plugin --profile web add link:<repo-path>> Windows + pnpm link: protocol note: some pnpm versions incorrectly concatenate link:D:\... backslash paths. Use forward slashes link:D:/...; if the junction still points to the wrong path, manually New-Item -ItemType Junction to fix the link inside node_modules.
Verify Installation
dsh --profile web --dump-config | grep tool-jwtRuntime Verification
dsh run "use the jwt tool to decode eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSJ9.sig"Tests
npm testbase64url.spec.ts: encode/decode all branches + invalid characters + non-string input + case/padding variantsjwt-decode.spec.ts: three-segment splitting + JSON parsing + expiry status (valid/expired/no-exp-claim/boundary) + size guardsjwt-sign.spec.ts: RFC 7515 A.1 official HS256 vector + self-sign round-trip + wrong key + expiry + leeway tolerance + tampering detection + non-HS256 algorithm rejectionregister.spec.ts: registration contract (AUDIT-CROSS-02 style)
License
MIT