A JSON Web Token (JWT) is a compact, URL-safe string that carries a signed set of claims about a user — who they are, what they can do, and when the token expires. Because the signature proves the token was issued by your server, you can authenticate a request by validating the token alone, without a session lookup. The @ooneex/jwt component wraps the JOSE library in a single injectable Jwt class that signs tokens with HS256, verifies their signature and expiration, and decodes their payload and header.
Why this component
- JOSE under the hood. Signing and verification run on the audited JOSE library, so you get correct, spec-compliant HS256 handling rather than hand-rolled crypto.
- Stateless auth. A verified token is proof of identity on its own — no session store, which fits horizontally scaled services and a middleware-driven request pipeline.
- Type-safe payloads.
create<T> and getPayload<T> are generic, so your custom claims (role, permissions, tenant) are typed end to end.
- Standard claims built in.
iss, sub, aud, exp, iat, nbf, and jti are first-class — set them on the payload and the class maps them to the right JOSE setters.
- Secret from the environment. The signing secret is read from
JWT_SECRET via AppEnv and injected by the container; the secret never lives in your code.
How it works
The Jwt class is injectable. Its constructor pulls AppEnv from the container and throws a JwtException immediately if JWT_SECRET is missing, so a misconfigured app fails fast at startup rather than at the first request.
| Stage | What happens |
|---|
| Construction | AppEnv is injected; if JWT_SECRET is unset, a JwtException (JWT_SECRET_REQUIRED) is thrown. |
| Signing | create() splits standard claims (iss, sub, exp, …) from custom claims, applies them via JOSE setters, and signs with HS256 using the encoded secret. |
| Verifying | isValid() calls jose.jwtVerify — it checks both the signature and the expiration, returning true or false (never throwing). |
| Decoding | getPayload() and getHeader() decode the token’s parts without any signature check. |
// The Jwt class is resolved by the container; the secret comes from JWT_SECRET.
import { inject } from "@ooneex/container";
import { Jwt } from "@ooneex/jwt";
constructor(@inject(Jwt) private readonly jwt: Jwt) {}
Methods
| Method | Signature | Description |
|---|
create | create<T>(config?: { payload?: JwtPayloadType<T>; header?: JWTHeaderParameters }): Promise<string> | Signs and returns a JWT string. Standard claims are read from payload; everything else becomes a custom claim. |
isValid | isValid(token: string): Promise<boolean> | Verifies signature and expiration. Returns true if valid, false on any failure — it does not throw. |
getPayload | getPayload<T>(token: string): JwtPayloadType<T> | Decodes and returns the payload without verifying the signature. |
getHeader | getHeader<T>(token: string): T | Decodes and returns the protected header (e.g. alg, typ, kid) without verifying. |
getSecret | getSecret(): string | Returns the raw JWT_SECRET used for signing and verifying. |
getPayload and getHeader only decode — they perform no cryptographic check. Anyone can craft a token whose payload says role: "admin". Always call isValid (or create it yourself) before trusting a decoded payload.
Algorithms and payload options
The component signs every token with HS256 (HMAC-SHA256, a symmetric algorithm using a shared secret). The header algorithm is fixed; the table below covers the claims and options you control through the payload.
| Option | Type | Claim | Description |
|---|
iss | string | Issuer | Who issued the token (e.g. your app or service name). |
sub | string | Subject | Who the token is about — typically the user id. |
aud | string | string[] | Audience | Intended recipient(s) of the token. |
exp | number | JwtExpiresInType | Date | Expiration | When the token stops being valid. Accepts a duration string like '15m'. |
iat | number | string | Date | Issued At | When the token was created. |
nbf | number | string | Date | Not Before | The token is invalid until this time. |
jti | string | JWT ID | A unique id for the token (useful for revocation lists). |
| (any other key) | unknown | custom | Anything not in the list above is signed as a custom claim and returned by getPayload. |
JwtExpiresInType is a relative-duration string accepted by exp:
| Suffix | Unit | Example |
|---|
s | seconds | '30s' |
m | minutes | '15m' |
h | hours | '2h' |
d | days | '7d' |
w | weeks | '1w' |
y | years | '1y' |
Signing a token
Issue a token on login. Put the user id in sub, set a short expiration, and add any custom claims you want to read back later.
interface AccessClaims {
role: string;
permissions: string[];
}
const token = await this.jwt.create<AccessClaims>({
payload: {
sub: "usr_abc123",
iss: "my-app",
aud: "my-api",
exp: "15m", // short-lived access token
role: "admin",
permissions: ["read", "write"],
},
});
You can also set protected header parameters such as a key id:
const token = await this.jwt.create({
payload: { sub: "usr_abc123", exp: "1h" },
header: { kid: "key-2026-06" },
});
Verifying a token
Verify on every request. isValid returns a boolean and never throws, so it is safe to branch on directly.
const ok = await this.jwt.isValid(token);
if (!ok) {
// signature failed or the token has expired
throw new Error("Invalid or expired token");
}
Decoding without verifying
getPayload and getHeader read the token’s contents without checking the signature — handy for inspecting an expired token, reading a kid before verification, or debugging. Treat the result as untrusted until isValid passes.
const header = this.jwt.getHeader(token);
console.log(header.alg); // "HS256"
const payload = this.jwt.getPayload<{ role: string }>(token);
console.log(payload.sub); // "usr_abc123"
console.log(payload.role); // "admin" — NOT yet verified
In an auth flow
The two halves of the flow are: issue a token when the user authenticates, then verify it in a middleware on every subsequent request. The middleware verifies first, then decodes, then attaches the user to the context for the controller. See Authentication for the full login flow and Users for the resolved user model.
import { inject } from "@ooneex/container";
import type { ContextType } from "@ooneex/controller";
import { Jwt } from "@ooneex/jwt";
import { decorator, type IMiddleware } from "@ooneex/middleware";
@decorator.middleware()
export class AuthMiddleware implements IMiddleware {
constructor(@inject(Jwt) private readonly jwt: Jwt) {}
public async handler(context: ContextType): Promise<ContextType> {
const authHeader = context.header.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
context.response.exception("Missing authorization header", { status: 401 });
return context;
}
const token = authHeader.substring(7);
// 1. Verify signature + expiration before trusting anything.
if (!(await this.jwt.isValid(token))) {
context.response.exception("Invalid or expired token", { status: 401 });
return context;
}
// 2. Now it is safe to decode and use the claims.
const payload = this.jwt.getPayload<{ role: string }>(token);
context.user = { id: payload.sub, role: payload.role };
return context;
}
}
Because the middleware short-circuits with context.response.exception(...) on failure, an unauthenticated request never reaches the controller. See Middleware for how the pipeline runs.
Error handling
The constructor throws a JwtException when JWT_SECRET is missing. JwtException extends the framework Exception, so it carries a message, a key, and a status. Catch it to distinguish JWT configuration errors from other failures.
import { Jwt, JwtException } from "@ooneex/jwt";
try {
// Resolving Jwt without JWT_SECRET set throws here.
const token = await this.jwt.create({ payload: { sub: "usr_abc123" } });
} catch (error) {
if (error instanceof JwtException) {
console.error(error.message); // "JWT secret is required. ..."
console.error(error.key); // "JWT_SECRET_REQUIRED"
console.error(error.status); // 500
}
}
Note that isValid does not throw on a bad or expired token — it returns false. Reserve try/catch for configuration and decoding errors (e.g. getPayload on a malformed string).
Best practices
- Keep the secret in the environment. Set
JWT_SECRET via AppEnv/.env, never in source. Use a long, random value and rotate it when needed.
- Short-lived access tokens. Prefer
exp: '15m' for access tokens and a separate longer-lived refresh token; a leaked access token then expires quickly.
- Never trust an unverified decode. Call
isValid before reading claims with getPayload. A decoded payload is attacker-controlled until the signature checks out.
- Pin the algorithm. The component signs only with HS256 — do not accept tokens whose header advertises a different
alg. Reject anything you did not issue.
- Set meaningful claims. Use
sub for the user id, iss/aud to scope a token to your app, and jti if you need per-token revocation.
- Verify in one place. Centralize verification in an auth middleware so every protected route enforces it consistently, rather than checking tokens in individual controllers.
- Authentication — the login flow that issues tokens.
- Users — the user model attached to the request after verification.
- Middleware — the pipeline where tokens are verified per request.
- Utilities: JWT — helper utilities for working with tokens.