Skip to content

JWT

JSON Web Tokens are the framework’s default bearer credential. JwtStrategy<C> is generic over the claims type — your app picks the shape, the framework verifies the signature, checks expiry, and hands the claims to the handler. Signing and verifying both run through JwtService, configured once via JwtConfig and injected wherever a token is minted.

The implementation is a thin wrapper over jsonwebtoken. The crate’s Algorithm enum is re-exported as nest_rs_authn::Algorithm so an app configures the algorithm without taking a direct dependency.

A resource-server app declares a type alias and a tiny module:

crates/features/src/authn/mod.rs
pub type AppJwtStrategy = JwtStrategy<Claims>;
pub type AuthGuard = nest_rs_authn::AuthGuard<AppJwtStrategy>;
#[module(
imports = [nest_rs_authn::AuthnModule::for_root(None)],
providers = [AppJwtStrategy, AuthGuard],
)]
pub struct AuthnModule;

AuthnModule::for_root(None) reads JwtConfig from the environment; pass a JwtConfig to pin it in code. The factory phase builds JwtService once and registers it as global infrastructure — controllers, login services, OAuth flows all inject the same instance.

One env var makes it work end to end — a shared HS256 secret (32 bytes minimum; anything shorter fails the boot):

Terminal window
export NESTRS_AUTHN__SECRET='a-32-byte-minimum-development-secret!!'

With that set, JwtService signs and verifies against the same secret: AuthGuard returns a 401 without a valid Authorization: Bearer token and attaches the claims otherwise. Every other knob is optional — the full env table and the HS256-vs-EdDSA decision come later on this page.

JwtStrategy<C> is an #[injectable] over an Arc<JwtService> and a PhantomData<C>. On each request it:

  1. Pulls Authorization: Bearer <token> out of the headers via bearer_token.
  2. Calls JwtService::verify::<C>(token) to verify the signature, check exp / nbf, and deserialize the claims.
  3. Returns Ok(claims) on success.

A missing header yields AuthError::MissingCredentials; a bad signature yields AuthError::InvalidSignature; an expired token yields AuthError::Expired. AuthGuard maps every variant to a 401 with WWW-Authenticate: Bearer.

Claims — your shape, your business rules

Section titled “Claims — your shape, your business rules”

The framework’s JwtStrategy<C> is generic. C is any DeserializeOwned + Clone + Send + Sync + 'static type — usually a struct with the standard sub, exp, plus whatever your app needs:

crates/features/src/identity/claims.rs
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: Option<Uuid>,
pub org_id: Uuid,
pub roles: Vec<Role>,
pub exp: u64,
}

The handler reads them back through Ctx<Claims>:

async fn create(&self, auth: Ctx<Claims>, body: Valid<Json<CreateUserDto>>)
-> Result<Json<User>>
{
Ok(Json(
self.svc.create_in_org(body.into_inner(), auth.org_id).await?,
))
}

A login or OAuth callback injects Arc<JwtService> and calls sign:

let claims = Claims {
sub: Some(user.id),
org_id: user.org_id,
roles: user.roles.clone(),
exp: self.jwt.expiry(),
};
let token = self.jwt.sign(&claims)?;

sign is Result<String, AuthError> — on a verify-only service (EdDSA with no private key), it returns AuthError::Failed(...). expiry() returns now + ttl from JwtOptions::expires_in (default one hour); ttl_secs() exposes the duration directly so the issuer can return it alongside the token.

Most code reaches the claims through Ctx<C>. A path that needs to verify a token manually — say, the OAuth transaction cookie, or a queue job carrying a signed envelope — injects Arc<JwtService> and calls verify:

let claims: Claims = self.jwt.verify(token)?;

The verification path runs Validation::leeway, checks nbf, and matches aud only when JwtConfig::audience is set — otherwise the audience check is disabled. Invalid signatures and expired tokens hit tracing::warn! under nest_rs::authn.

JwtConfig follows the framework-wide dual-path rule: every field is settable from NESTRS_AUTHN__* env vars or from a pinned struct passed to AuthnModule::for_root(config).

FieldEnv varNotes
secretNESTRS_AUTHN__SECRETHS256 shared secret
private_keyNESTRS_AUTHN__PRIVATE_KEYEdDSA PEM, issuer only
public_keyNESTRS_AUTHN__PUBLIC_KEYEdDSA PEM, verifier needs it
leeway_secsNESTRS_AUTHN__LEEWAY_SECSClock skew, default 30
audienceNESTRS_AUTHN__AUDIENCEaud claim — unset disables the check

JwtConfig::into_options walks the three key fields and chooses a mode:

  • secret set → HMAC HS256 (both sign and verify).
  • private_key + public_key → EdDSA (sign and verify).
  • public_key alone → EdDSA verify-only — a resource server with no private key cannot mint a token even if it wanted to.
  • nothing → boot fails with "no JWT key configured".

A misconfiguration (private_key without public_key) fails the boot loudly, naming the offending env var.

HS256 vs EdDSA — pick on deployment shape

Section titled “HS256 vs EdDSA — pick on deployment shape”

HS256 is fine when one binary both issues and verifies tokens. Symmetric — anyone with the secret can mint a token. Convenient for local development and single-process apps. Set NESTRS_AUTHN__SECRET.

EdDSA is the production posture for split deployments. The issuer holds the private PEM; every resource server holds the public PEM only. Even a compromised resource server cannot mint a token. The issuer sets both PRIVATE_KEY and PUBLIC_KEY; verifiers set only PUBLIC_KEY.

See Issuer and resource server for the deployment pattern.

Terminal window
# generate an EdDSA key pair
openssl genpkey -algorithm ed25519 -out jwt.pem
openssl pkey -in jwt.pem -pubout -out jwt.pub
  • Issuer and resource server — the split-deploy pattern that pairs with EdDSA.
  • OAuth2 — the JWT issuer is often the OAuth callback.
  • Authorization — once the claims land on the request, the authz layer builds an Ability from them.