TokenHandler
createTokenHandler is the core of the library. Where many libraries mainly expose utility functions that you have to assemble yourself, the idea here is the opposite: you start by creating a handler that already carries the token management policy.
This handler brings together in one place:
- signing;
- encryption;
- token lifetime;
- claim management for header and payload;
In other words, the goal is to build a clear contract that you can reuse.
Created at startup
createTokenHandler is meant to be called when the application starts. The configuration is validated immediately, and an invalid configuration throws as soon as the handler is created.
Simple example
import { asyncPipe, D, DPE, E } from "@duplojs/utils";
import { Signer, createTokenHandler } from "@duplojs/json-web-token";
const tokenHandler = createTokenHandler({
maxAge: D.createTime(15, "minute"),
signer: Signer.createHS256({ secret: "my-secret" }),
issuer: "my-app",
audience: ["web"],
customPayloadShape: {
userId: DPE.string(),
},
});
const token = await tokenHandler.createOrThrow({
userId: "1",
});
// send to client ...
const result = await asyncPipe(
"receive-token",
tokenHandler.verify,
E.whenIsRight(
({ payload }) => {
const userId = payload.userId;
},
),
);What happens here
When createOrThrow runs, the handler adds standard claims such as iat and exp, then signs the content.
When verify runs, it decodes the token again, verifies the signature, then applies configuration checks such as expiration, issuer, subject, or audience.
verify returns an either result. On success, the result information is token-verified and the value contains the decoded header and payload.
Why prefer createOrThrow
In practice, we recommend createOrThrow.
If token creation fails, it is usually a server-side implementation or configuration issue, not an expected business scenario. In that case, throwing immediately is often the healthiest behavior.
By contrast, verify has no orThrow variant: a verification failure can be perfectly normal (invalid signature, expired token, inconsistent claims, etc.). It is not necessarily an implementation error, so this case remains handled as a business-level result.
Parameters
interface TokenHandlerParams {
maxAge: D.TheTime;
signer: Signer<string> | CreateSigner<string, unknown>;
cipher?: Cipher<string> | CreateCipher<string, unknown>;
issuer?: string;
subject?: string;
audience?: string | string[];
now?: () => D.TheDate;
customPayloadShape: DP.DataParserObjectShape;
customHeaderShape?: DP.DataParserObjectShape;
};The returned handler then exposes four methods:
create: creates a token and returnstoken-createdor a creation error.createOrThrow: creates a token string directly, or throws when creation fails.verify: verifies signature and configured claims, then returnstoken-verifiedor a verification error.decode: reads header and payload without checking signature or claims, then returnstoken-decodedor a decode error.
Example with custom shapes
import { D, DPE, E, unwrap } from "@duplojs/utils";
import { Signer, createTokenHandler } from "@duplojs/json-web-token";
const tokenHandler = createTokenHandler({
maxAge: D.createTime(1, "hour"),
signer: Signer.createHS256({ secret: "my-secret" }),
issuer: "admin-app",
customPayloadShape: {
userId: DPE.string(),
role: DPE.literal("admin"),
},
customHeaderShape: {
kid: DPE.string().optional(),
},
});
const token = await tokenHandler.createOrThrow(
{
userId: "42",
role: "admin",
},
{
header: {
kid: "main",
},
},
);
// send to client ...
const decodedTokenResult = await tokenHandler.decode("receive-token");
if (E.isRight(decodedTokenResult)) {
const decodedToken = unwrap(decodedTokenResult);
const userId = decodedToken.payload.userId;
}What happens here
customPayloadShape and customHeaderShape define what your application is allowed to put into the token.
Reserved JWT keys such as exp, iat, iss, sub, aud, typ, or alg remain managed by the handler itself.
decode only reads the token content. It is useful for inspection, but it should not be used to trust a received token.
Example with creators
import { D, DPE, E, unwrap } from "@duplojs/utils";
import { Cipher, Signer, createTokenHandler } from "@duplojs/json-web-token";
const tokenHandler = createTokenHandler({
maxAge: D.createTime(10, "minute"),
signer: Signer.createHS256,
cipher: Cipher.createRSAOAEP,
customPayloadShape: {
userId: DPE.string(),
},
});
const token = await tokenHandler.createOrThrow(
{
userId: "1",
},
{
signer: {
secret: "my-secret",
},
cipher: {
privateKey: "private-key",
publicKey: "public-key",
},
},
);
// send to client ...
const verifiedTokenResult = await tokenHandler.verify("receive-token", {
signer: {
secret: "my-secret",
},
cipher: {
privateKey: "private-key",
publicKey: "public-key",
},
});
if (E.isRight(verifiedTokenResult)) {
const verifiedToken = unwrap(verifiedTokenResult);
const userId = verifiedToken.payload.userId;
}What happens here
When you pass a CreateSigner or a CreateCipher instead of an already configured instance, the parameters move to create, createOrThrow, verify, and decode.
This lets you create the handler only once, while injecting secrets, keys, or other required parameters later.
The success result stays the same: create returns token-created, decode returns token-decoded, and verify returns token-verified.
