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.
The three-line setup
Section titled “The three-line setup”A resource-server app declares a type alias and a tiny module:
use nest_rs::authn::JwtStrategy;
use super::Claims;
pub type AuthnStrategy = JwtStrategy<Claims>;
pub type AuthnGuard = nest_rs::authn::AuthnGuard<AuthnStrategy>;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):
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.
What JwtStrategy does
Section titled “What JwtStrategy does”JwtStrategy<C> is an #[injectable] over an Arc<JwtService> and a
PhantomData<C>. On each request it:
- Pulls
Authorization: Bearer <token>out of the headers viabearer_token. - Calls
JwtService::verify::<C>(token)to verify the signature, checkexp/nbf, and deserialize the claims. - 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:
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>:
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?, ))}Signing a token
Section titled “Signing a token”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.
Verifying outside the guard
Section titled “Verifying outside the guard”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
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.
Configuration
Section titled “Configuration”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).
| Field | Env var | Notes |
|---|---|---|
secret | NESTRS_AUTHN__SECRET | HS256 shared secret |
private_key | NESTRS_AUTHN__PRIVATE_KEY | EdDSA PEM, issuer only |
public_key | NESTRS_AUTHN__PUBLIC_KEY | EdDSA PEM, verifier needs it |
leeway_secs | NESTRS_AUTHN__LEEWAY_SECS | Clock skew, default 30 |
audience | NESTRS_AUTHN__AUDIENCE | aud claim — set makes it mandatory; unset still refuses a foreign aud |
issuer | NESTRS_AUTHN__ISSUER | iss claim — set makes it mandatory; unset means no issuer check (§4.1.1 states no rejection clause) |
expires_in_secs | NESTRS_AUTHN__EXPIRES_IN_SECS | Minted-token lifetime, default 3600 |
explicit_typing | NESTRS_AUTHN__EXPLICIT_TYPING | RFC 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_audience | NESTRS_AUTHN__ALLOW_ANY_AUDIENCE | Opt 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:
secretset → HMAC HS256 (both sign and verify).private_key+public_key→ EdDSA (sign and verify).public_keyalone → 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.
# generate an EdDSA key pairopenssl genpkey -algorithm ed25519 -out jwt.pemopenssl pkey -in jwt.pem -pubout -out jwt.pubGoing further
Section titled “Going further”- 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
Abilityfrom them.