Issuer and resource server
A single app that both issues tokens and serves protected routes is
fine — the secret stays in one process, the wiring is one
AuthnModule. The pattern fights you when a second deployable joins:
two binaries can either share the signing secret (and any compromise
mints valid tokens for both) or one becomes the issuer and the other
verifies what it produces. nestrs leans hard on the second posture.
apps/auth is the exemplar issuer. apps/api is
the exemplar resource server. They share a Postgres database and the
crates/features code; they never RPC each other.
The split
Section titled “The split”| Concern | Issuer (apps/auth) | Resource server (apps/api) |
|---|---|---|
| JWT private key | Holds it | Never sees it |
| JWT public key | Holds it | Holds it |
| Token endpoints | /token, /login, OAuth callback | None |
| Protected routes | None | /users, /orgs, /graphql, /ws, … |
| Database | Yes (shared) | Yes (shared) |
| Cross-app calls | None | None |
The two binaries see the same Claims type via the shared
crates/features::identity crate. The verifier deserializes a token
the issuer signed; both speak the same struct, no schema drift
possible.
The keys
Section titled “The keys”Use EdDSA. Generate one key pair, store the private PEM on the issuer only, distribute the public PEM to every verifier:
openssl genpkey -algorithm ed25519 -out jwt.pemopenssl pkey -in jwt.pem -pubout -out jwt.pubThen on the issuer:
export NESTRS_AUTHN__PRIVATE_KEY="$(cat jwt.pem)"export NESTRS_AUTHN__PUBLIC_KEY="$(cat jwt.pub)"On every resource server:
export NESTRS_AUTHN__PUBLIC_KEY="$(cat jwt.pub)"# NESTRS_AUTHN__PRIVATE_KEY is unset — the boot picks `eddsa_verify`JwtConfig::into_options walks the three key env vars and chooses
verify-only mode when only the public key is set. JwtService::sign
on a verify-only service returns AuthError::Failed("this JwtService is verify-only — no signing key configured") — even a buggy resource
server cannot mint a token.
A misconfiguration (PRIVATE_KEY set, PUBLIC_KEY missing) fails the
boot loudly with "NESTRS_AUTHN__PRIVATE_KEY is set without NESTRS_AUTHN__PUBLIC_KEY".
Two main files, one feature set
Section titled “Two main files, one feature set”#[module( imports = [ ConfigModule::for_root(), DatabaseModule::for_root(None), AuthnModule::for_root(None), OAuth2Module::for_root(None), ThrottlerModule::for_root(None), OAuthHttpModule, HealthModule, OpenTelemetryModule, HttpModule::for_root(HttpConfig { port: 3001, ..Default::default() }), ],)]pub struct AuthModule;The issuer imports the OAuth flow controller (OAuthHttpModule) and
the throttler. It does not import UsersHttpModule — the issuer
mints tokens, not users.
#[module( imports = [ ConfigModule::for_root(), DatabaseModule::for_root(None), AuthnModule::for_root(None), AuthzHttpModule, AuthzGraphqlModule, UsersHttpModule, OrgsHttpModule, HealthModule, OpenTelemetryModule, HttpModule::for_root(HttpConfig { port: 3000, ..Default::default() }), ],)]pub struct ApiModule;The resource server imports the authz transports and every feature
controller. Same AuthnModule — but with only the public key in
env, it loads as verify-only.
Why not RPC
Section titled “Why not RPC”The two binaries deliberately do not talk to each other. They share a
database, and each owns the table it writes to (the issuer owns
users for signin; the resource server reads users for queries —
both go through the same SeaORM model). Cross-app chatter would
reintroduce the latency, the failure modes, and the protocol drift
the split was supposed to avoid.
When a resource server needs to validate something the issuer knows
(say, a refresh token was revoked), put it in the database — the
issuer writes the revocation row, the resource server reads it via
the same Repo. No HTTP between the two.
Run them together
Section titled “Run them together”nestrs run dev auth # issuer on :3001nestrs run dev api # resource server on :3002A round-trip:
TOKEN=$(curl -sX POST http://localhost:3001/login \ -H 'Content-Type: application/json' \ -d '{"email":"ada@example.com","password":"hunter2"}' \ | jq -r .access_token)
curl -s http://localhost:3002/users \ -H "Authorization: Bearer $TOKEN" | jq '.[].name'The bearer is signed by the issuer’s private key; the API verifies with the public key. The API has no way to mint that token even if it wanted to.
Going further
Section titled “Going further”- JWT —
JwtConfig,JwtKey, EdDSA vs HS256. - OAuth2 — the flow the issuer exposes upstream.
- Threat model — what the asymmetric split buys you, and what it does not.