Skip to content

JWT

JwtStrategy verifies a bearer token, JwtService signs and verifies, and the resource server holds only the public key when EdDSA is on.

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/strategy.rs (from the demo)
use nest_rs::authn::JwtStrategy;
use super::Claims;
pub type AuthnStrategy = JwtStrategy<Claims>;
pub type AuthnGuard = nest_rs::authn::AuthnGuard<AuthnStrategy>;
crates/features/src/authn/module.rs (from the demo)
use nest_rs::core::module;
use super::strategy::{AuthnGuard, AuthnStrategy};
#[module(
imports = [nest_rs::authn::AuthnModule::for_root(None)],
providers = [AuthnStrategy, AuthnGuard],
)]
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: AuthnGuard 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. AuthnGuard 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 + PrincipalIdentity + Clone + Send + Sync + 'static type — usually a struct with the standard sub, exp, plus whatever your app needs:

crates/features/src/authn/claims.rs (from the demo)
use nest_rs::authn::PrincipalIdentity;
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,
}
impl PrincipalIdentity for Claims {
fn actor_id(&self) -> Option<String> {
self.sub.map(|id| id.to_string())
}
}

PrincipalIdentity is the one required impl. On a successful authentication AuthnGuard records actor_id onto the request span, so every downstream event — denials included — is attributable without threading the identity through each call site. Return None for a principal that genuinely has no identity (a machine token scoped to an org, say). nestrs g auth writes both the struct and the impl.

The handler reads them back through Ctx<Claims>:

crates/features/src/users/http/controller.rs
async fn create(&self, auth: Ctx<Claims>, body: Valid<Json<CreateUser>>)
-> 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:

crates/features/src/authn/service.rs
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:

crates/features/src/authn/service.rs
let claims: Claims = self.jwt.verify(token)?;

The verification path runs Validation::leeway, checks nbf, and applies RFC 7519 §4.1.3 to aud always: a token carrying an audience this service is not named in is rejected whether or not JwtConfig::audience is set. Setting it adds the other direction — the claim becomes mandatory, so a token omitting aud is rejected too. Verification failures here log at tracing::debug! under nest_rs::authn, carrying the typed decode reason — and an expired token logs nothing at all. Both follow from “one event, said once”: the single warn for an authentication failure is the guard’s, which has the strategy and the route to name, so a second line from the service would double-count every denial.

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 — set makes it mandatory; unset still refuses a foreign aud
issuerNESTRS_AUTHN__ISSUERiss claim — set makes it mandatory; unset means no issuer check (§4.1.1 states no rejection clause)
expires_in_secsNESTRS_AUTHN__EXPIRES_IN_SECSMinted-token lifetime, default 3600
explicit_typingNESTRS_AUTHN__EXPLICIT_TYPINGRFC 9068 §2.1/§4 — stamp and require typ: at+jwt, default true. Turn off only for a legacy issuer minting plain typ: JWT
allow_any_audienceNESTRS_AUTHN__ALLOW_ANY_AUDIENCEOpt out of §4.1.3, default false. Refused beside audience; warns once per boot

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 Split deployment 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
  • Split deployment — 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.