Skip to content

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.

The split is a deployment shape; the crates divide once more narrowly, one per RFC 6749 §1.1 role. Both halves need authnJwtService 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.

Terminal window
# the issuer — signs tokens, so it serves a token endpoint
cargo add nest-rs --features oauth-server
# and, on both halves
cargo add nest-rs --features authn

Being 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.

Terminal window
cargo add nest-rs --features oauth-resource

So 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.

ConcernIssuer (apps/auth)Resource server (apps/api)
JWT private keyHolds itNever sees it
JWT public keyHolds itHolds it
Token endpoints/token, /login, OAuth callbackNone
Protected routesNone/users, /orgs, /graphql, /ws, …
DatabaseYes (shared)Yes (shared)
Cross-app callsNoneNone

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.

Use EdDSA. Generate one key pair, store the private PEM on the issuer only, distribute the public PEM to every verifier:

Terminal window
openssl genpkey -algorithm ed25519 -out jwt.pem
openssl pkey -in jwt.pem -pubout -out jwt.pub

Then on the issuer:

Terminal window
export NESTRS_AUTHN__PRIVATE_KEY="$(cat jwt.pem)"
export NESTRS_AUTHN__PUBLIC_KEY="$(cat jwt.pub)"

On every resource server:

Terminal window
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".

apps/auth/src/module.rs (from the demo)
#[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.

apps/api/src/module.rs (from the demo)
#[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.

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.

Terminal window
nestrs run dev auth # issuer on :3001
nestrs run dev api # resource server on :3002

A round-trip:

Terminal window
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.

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.

apps/api/src/module.rs
#[module(imports = [
HttpModule::for_root(None),
AuthnModule::for_root(None),
OAuthResourceModule::for_root(None),
])]
pub struct ApiModule;
Terminal window
NESTRS_OAUTH_RESOURCE__RESOURCE=https://api.example.com
NESTRS_OAUTH_RESOURCE__AUTHORIZATION_SERVERS=https://auth.example.com
NESTRS_AUTHN__AUDIENCE=https://api.example.com
NESTRS_OAUTH_RESOURCE__SCOPES_SUPPORTED=users:read,users:write

Three 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 varDocument memberWhat it says
NESTRS_OAUTH_RESOURCE__RESOURCE_NAMEresource_namea human-readable name for this resource (§2 RECOMMENDs it)
NESTRS_OAUTH_RESOURCE__RESOURCE_DOCUMENTATIONresource_documentationwhere a developer reads about it
NESTRS_OAUTH_RESOURCE__RESOURCE_POLICY_URIresource_policy_uriwhere a developer reads how this resource’s data may be used
NESTRS_OAUTH_RESOURCE__RESOURCE_TOS_URIresource_tos_urithe terms of service
NESTRS_OAUTH_RESOURCE__BEARER_METHODS_SUPPORTEDbearer_methods_supportedhow 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:

Terminal window
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 Forbidden
WWW-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.

  • JWTJwtConfig, JwtKey, EdDSA vs HS256.
  • OAuth2 — the flow the issuer exposes upstream.
  • Threat model — what the asymmetric split buys you, and what it does not.