Split deployment
A single signer issues tokens, every API verifies them — split the deployment, keep the keys asymmetric, never RPC across the boundary.
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.
Two vocabularies meet on this page, so here is which is which. RFC 6749 §1.1
names the roles — authorization server and resource server — and that is
what the crates are named for: nest-rs-oauth-server and
nest-rs-oauth-resource. Below, apps/auth is called the issuer, because
everything that follows is about the signing key, and issuer is RFC 7519
§4.1.1’s own word for whoever holds it — the iss claim in every token it
mints. Same app, two standards, one deployment.
Install
Section titled “Install”The split is a deployment shape; the crates divide once more narrowly, one per
RFC 6749 §1.1 role. Both halves need authn — JwtService signs and verifies,
and that is the question preceding all four roles. Only the issuer needs
oauth-server, which holds the §5.2 token-endpoint codes and the §2.3.1
machine-client registry: neither is a credential verdict, and a resource server
that compiled them would carry a token endpoint it never serves.
# the issuer — signs tokens, so it serves a token endpointcargo add nest-rs --features oauth-server
# and, on both halvescargo add nest-rs --features authnBeing discoverable is what stands apart, and it has its own capability: the
RFC 9728 document and the 401 challenge describe the deployment, are served
to callers with no credential at all, and mount an HTTP controller a worker
would never reach.
cargo add nest-rs --features oauth-resourceSo apps/auth enables authn; apps/api enables authn and
oauth-resource; a queue worker that only verifies a JWT enables authn alone
and compiles no controller, no interceptor and no outbound HTTP client.
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::authn 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(), OpenTelemetryModule, SeaOrmModule::for_root(None), SeaOrmDatabaseModule, SeaOrmHealthModule, ThrottlerModule::for_root(None), HealthModule, HttpModule::for_root(HttpConfig { port: 3001, ..Default::default() }), AuthnModule::for_root(None), SocialModule, OAuthHttpModule, ],)]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(), SeaOrmModule::for_root(None), SeaOrmDatabaseModule, HealthModule, HttpModule::for_root(HttpConfig { port: 3002, compression: true, ..Default::default() }), AuthnModule, AuthzModule, AuthzGraphqlModule, OrgsHttpModule, UsersHttpModule, ],)]pub struct ApiModule;The resource server imports the authz transports and every feature controller. With only the public key in env, its authn loads as verify-only.
The two lists import different modules, and the names say which.
apps/auth takes nest_rs::authn::AuthnModule — the framework’s, which
provides JwtService and carries the config seam. apps/api takes
features::authn::AuthnModule — the product’s own, which wraps that one
and adds the Claims-bound strategy and guard; it owns no configuration, so it
has no seam. The app_ prefix is what makes the pair readable without going
back to the use line.
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.
Telling clients where the issuer is
Section titled “Telling clients where the issuer is”An OAuth client that has never met your API does not know which
authorization server to ask. OAuthResourceModule answers that,
the way RFC 9728 and
the MCP authorization spec require: it serves
/.well-known/oauth-protected-resource, and it stamps a pointer to
that document onto every 401 the app emits.
#[module(imports = [ HttpModule::for_root(None), AuthnModule::for_root(None), OAuthResourceModule::for_root(None),])]pub struct ApiModule;NESTRS_OAUTH_RESOURCE__RESOURCE=https://api.example.comNESTRS_OAUTH_RESOURCE__AUTHORIZATION_SERVERS=https://auth.example.comNESTRS_AUTHN__AUDIENCE=https://api.example.comNESTRS_OAUTH_RESOURCE__SCOPES_SUPPORTED=users:read,users:writeThree optional members of the RFC 9728 document round out the deployment’s description of itself. Each is omitted from the served JSON when unset, as §3.2 requires:
| Env var | Document member | What it says |
|---|---|---|
NESTRS_OAUTH_RESOURCE__RESOURCE_NAME | resource_name | a human-readable name for this resource (§2 RECOMMENDs it) |
NESTRS_OAUTH_RESOURCE__RESOURCE_DOCUMENTATION | resource_documentation | where a developer reads about it |
NESTRS_OAUTH_RESOURCE__RESOURCE_POLICY_URI | resource_policy_uri | where a developer reads how this resource’s data may be used |
NESTRS_OAUTH_RESOURCE__RESOURCE_TOS_URI | resource_tos_uri | the terms of service |
NESTRS_OAUTH_RESOURCE__BEARER_METHODS_SUPPORTED | bearer_methods_supported | how a token may be presented. §2’s defined values are header, body and query; the framework reads a bearer token only from the Authorization header, so anything but header fails the boot rather than advertising a form this server refuses |
§2 defines fourteen members and the framework serves eight. The other six are refused in writing on ProtectedResourceMetadata, each
naming the fact that makes it impossible here: nothing signs a response, so
no jwks_uri; no cnf-claim handling, so no DPoP binding; a Strategy
never sees the TLS handshake, so no mTLS binding. signed_metadata is
recorded as an owner question rather than a refusal — publishing one is
possible, and only the validating half is missing.
The discovery walk, from a client that knows only one URL:
curl -si https://api.example.com/users | grep -i www-authenticate# www-authenticate: Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource", scope="users:read users:write"
curl -s https://api.example.com/.well-known/oauth-protected-resource# {"resource":"https://api.example.com","authorization_servers":["https://auth.example.com"], …}The metadata route is declared #[public], because a client cannot
hold a token before it has read the document that says where to get
one. Everything else stays closed.
When RESOURCE carries a path — https://api.example.com/mcp — the
document is published at the path-aware URL RFC 9728 §3.1 defines,
https://api.example.com/.well-known/oauth-protected-resource/mcp, and
that is what the challenge advertises. The unsuffixed URL stays served
for the bare-origin case and for clients that skip the challenge; a tail
that is not this resource’s path answers 404 rather than claiming an
identity the deployment does not have.
The second refusal: a token that is merely too narrow
Section titled “The second refusal: a token that is merely too narrow”The 401 above is “you have no token”. Its counterpart is “your token
verified, but it was not delegated enough” — a 403 carrying the RFC
6750 §3.1 challenge:
HTTP/1.1 403 ForbiddenWWW-Authenticate: Bearer error="insufficient_scope", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource", scope="users:write"Both are written by the same interceptor at the transport edge, so HTTP,
WS and MCP get them identically and neither can drift from the metadata
document. What raises the 403 is a scope-gated authorization rule —
see OAuth scopes. Make sure every
scope your rules gate on appears in SCOPES_SUPPORTED, or you are
telling clients to request something discovery never names.
NESTRS_AUTHN__AUDIENCE stops being optional. On its own,
JwtConfig treats it as a nice-to-have; under this module boot fails
without it, naming the variable. The confused-deputy defence itself is
not what the module adds — JwtService applies RFC 7519 §4.1.3 on every
path, so a token minted for another named service is refused even by an
app that pins no audience. What pinning adds is the token that carries no
aud at all: without a pinned audience such a token is accepted, and a
resource server cannot afford that. RFC 8707 makes the resource
identifier be the audience — keep the two strings identical, and the
framework warns at boot when they drift.
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.