Fix secretOrPrivateKey must have a value in NestJS JWT
Reproduced on @nestjs/jwt 12.0.3 and 10.0.3: why a configured JwtModule still signs with an empty key, why two popular answers fall short, and the .env load-order variant of the same error.
TL;DR#
- Cause (most common):
JwtServiceis listed in theprovidersarray of the module that importsJwtModule.register(...). Nest then builds a secondJwtServiceinside that module. That copy never receives the module options, so it signs with an empty key and thejsonwebtokenlibrary throws. - Fix: delete
JwtServicefromproviders. KeepJwtModule.register(...)(orregisterAsync) inimports, and keep injectingJwtServicein the constructor. - Second cause, same message: the secret really is
undefinedbecause aconstants.tsreadsprocess.env.JWT_SECRETbeforeConfigModule.forRoot()has loaded.env. Switch toJwtModule.registerAsyncwithConfigService. - Reproduced on @nestjs/jwt 12.0.3 with Nest 12.2.0 and on @nestjs/jwt 10.0.3 with Nest 9.4.3, Node 24 in a
node:24-alpinecontainer, 24 October 2026.
The error#
The Stack Overflow question configures JwtModule.register({ global: true, secret: jwtConstants.secret, ... }) in AuthModule, confirms that JWT_SECRET is set, and still gets this from this.jwtService.signAsync(payload):
[s1 JwtService listed in providers] Error: secretOrPrivateKey must have a valueThat is the output of a minimal copy of the question's module, with a hard-coded secret so the environment cannot be blamed. The module that produced it:
@Module({
imports: [
JwtModule.register({
global: true,
secret: 'a-32-byte-or-longer-test-secret-value',
signOptions: { expiresIn: '60s' },
}),
],
providers: [AuthService, JwtService],
})
class AuthModule {}The secret is right there, and the error says there is none. Both statements are true, because there are two JwtService objects in this application.
Why a configured JwtModule still signs with no key#
The message itself comes from jsonwebtoken, not from Nest. In its sign.js, any falsy key is rejected before anything else happens:
if (!secretOrPrivateKey && options.algorithm !== 'none') {
return failure(new Error('secretOrPrivateKey must have a value'));
}So the question is why @nestjs/jwt passed an empty key. The answer is in the JwtService constructor (jwt.service.ts):
@Injectable()
export class JwtService {
constructor(
@Optional()
@Inject(JWT_MODULE_OPTIONS)
private readonly options: JwtModuleOptions = {}
) {}
// sign(), signAsync(), verify()… read this.options
}The options are injected through a token, and the injection is optional: if no provider for that token is visible, Nest does not fail, it passes nothing and the default {} applies. Now look at what JwtModule.register() returns in jwt.module.ts: a dynamic module whose providers hold the options, while the static @Module metadata exports JwtService only. The options token stays private to JwtModule.
When AuthModule lists JwtService in its own providers, Nest treats it like any other class you declare there and instantiates it in AuthModule's scope. AuthService asks for JwtService, and Nest resolves the local one first. That instance looks for the options token, finds none in AuthModule, gets {}, and every sign() or signAsync() without a per-call secret ends with an empty key. The configured instance inside JwtModule exists too; it is just not the one being injected.
global: true does not change any of this. It makes JwtModule's exported JwtService visible everywhere, but a module that declares its own provider for the same class still uses its own.
Fix: let JwtModule own JwtService#
- Remove
JwtServicefromprovidersin every module that importsJwtModuleor relies on it being global. Leave the import of the class inauth.service.ts, because the constructor still needs it as a type token.
@Module({
imports: [
JwtModule.register({
global: true,
secret: jwtConstants.secret,
signOptions: { expiresIn: '60s' },
}),
],
providers: [AuthService, UserService, EmailService],
controllers: [AuthController],
})
export class AuthModule {}-
Drop
secretOrPrivateKeyfrom the options while you are there. The question sets bothsecretandsecretOrPrivateKey; the@nestjs/jwtREADME markssecretOrPrivateKeyas deprecated, and the reproduction shows what keeping it costs (see below). -
If
JWT_SECRETcomes from a.envfile, read it throughConfigServicerather than at import time (next section). With the hard-coded secret in the reproduction, step 1 alone was enough:
[s2 JwtService removed from providers] signed: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…This is also what the official Passport recipe does: its AuthModule imports JwtModule.register({ secret: jwtConstants.secret, signOptions: { expiresIn: '60s' } }) and lists AuthService, LocalStrategy and — once the JWT guard is added — JwtStrategy. JwtService never appears in a providers array.
Verify the fix#
Run the sign-in route again and decode the returned token's header. A token starting with eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 is the base64 of an HS256 JWT header, which is what the reproduction printed after the fix. Then search the codebase for the provider so it does not come back in another feature module:
grep -rn "JwtService" src --include="*.module.ts"The import { JwtService } from '@nestjs/jwt' statement at the top of a file is expected and harmless. Only a JwtService entry inside a providers array is the bug.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
Two popular answers, tested#
The question's other answers are each partly right, so both were run against the broken module.
Moving JwtModule to AppModule. One answer says to remove JwtModule from AuthModule's imports and add it to AppModule. Done literally, with JwtService still in AuthModule's providers, the error stays:
[s3 JwtModule in AppModule, JwtService still in providers] Error: secretOrPrivateKey must have a valueIn the reproduction, moving the import on its own left the error unchanged; only removing the duplicate provider made signing work.
Passing the secret on every call. Another answer changes the call to signAsync(payload, { secret }). That does sign, because the per-call secret takes precedence over the (empty) module options. But the duplicate instance is still the one injected, and the first call that relies on the module options fails. Verifying the token with the same injected service, without a per-call secret, as a guard would:
[s4 per-call secret, duplicate provider kept] signed: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…
[s4 verifyAsync without secret] JsonWebTokenError: secret or public key must be providedThat second message is the verify.js twin of the signing error, and the reason the per-call secret is a mask rather than a fix: signOptions such as expiresIn: '60s' from the module options are also missing from that instance.
When the secret really is undefined: .env read too early#
The second way to get the same message is an honest undefined. The question's constants.ts is:
export const jwtConstants = {
secret: process.env.JWT_SECRET,
};If .env is loaded by ConfigModule.forRoot() in AppModule, that object is built too early. auth.module.ts imports constants.ts, and JwtModule.register({ secret: jwtConstants.secret }) runs while auth.module.ts is evaluated, which happens before AppModule's decorator calls ConfigModule.forRoot(). The NestJS configuration docs state the same ordering rule for the other loader: process.loadEnvFile() "must run before AppModule is imported, because forRoot() reads process.env while the app.module file is being evaluated."
The reproduction, with a correct .env and no duplicate provider, logged:
[s6] when AuthModule was decorated, jwtConstants.secret = undefined
[s6 secret read from process.env in constants.ts] Error: secretOrPrivateKey must have a value
[s6] after ConfigModule.forRoot(), process.env.JWT_SECRET is setThis also explains a common debugging trap: a console.log(process.env.JWT_SECRET) placed in a constructor or a route handler prints the value, because by then .env has been loaded. The value captured in jwtConstants was read earlier.
The fix is to read the secret when the module options are built, not when the file is imported. registerAsync with a factory, as shown in the README:
@Module({
imports: [
JwtModule.registerAsync({
global: true,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_SECRET'),
signOptions: { expiresIn: '60s' },
}),
}),
],
providers: [AuthService],
})
export class AuthModule {}getOrThrow() is documented in the same configuration page: it "throws an error if the resolved value is undefined". A missing variable then fails at startup with its name, instead of at the first sign-in with a message about secretOrPrivateKey. Run in the same project:
[s7 registerAsync + ConfigService] signed: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…The deprecation warning after the fix#
With the duplicate provider removed but the question's secretOrPrivateKey key still in register(), signing works and Nest logs a warning on each call that resolves a key:
[Nest] 548 - 10/24/2026, 3:49:55 AM WARN [JwtService] "secretOrPrivateKey" has been deprecated, please use the new explicit "secret" or use "secretOrKeyProvider" or "privateKey"/"publicKey" exclusively.The warning is emitted by overrideSecretFromOptions in jwt.service.ts, and that same function shows the deprecated key wins when both are set. Keep secret and delete secretOrPrivateKey.
Versions, and errors that come next#
The same duplicate-provider module was run on the releases that were current when the question was asked on 13 June 2023, as a CommonJS project: @nestjs/jwt 10.0.3 (with jsonwebtoken 9.0.0) on Nest 9.4.3 produced the same error with JwtService in providers, and a signed token without it. The constructor with the optional options token is the same in the 10.0.3 source. The current releases are published differently: the @nestjs/jwt 12 and Nest 12 packages declare "type": "module" and ship ES modules. If an upgrade to them breaks your build before you even reach this error, see exports is not defined in ES module scope, which covers how Node picks CommonJS or ESM per file and started from a NestJS project.
Once a key is reaching jsonwebtoken, sign.js has two more checks that read similarly: secretOrPrivateKey must be a symmetric key when using HS256 when an RSA private key is passed with the default algorithm, and secretOrPrivateKey is not valid key material when the value is neither a key nor a usable secret. Those are configuration mistakes in the key itself, not in dependency injection.
If the secret is set in a container and still arrives empty, check how the environment reaches the process: a - "JWT_SECRET: value" item (the whole line quoted) has no =, so Compose creates a variable named after the whole text with no value, while the unquoted - JWT_SECRET: value form stops docker compose up with an 'unexpected type map' error, as explained in the Docker Compose 'unexpected type map' error. For a NestJS image that exits before any route runs, the build itself may be the problem: Cannot find module '/dist/main' in a Docker build covers the Dockerfile ordering and rootDir causes. A similar "the secret is missing where the code reads it" failure exists in next-auth v4, where a secret absent from the production environment sends sign-ins to /api/auth/error (NO_SECRET in production).
In short: when NestJS says secretOrPrivateKey must have a value, first look for JwtService in a providers array and delete it. If there is none, log jwtConstants.secret at the place JwtModule.register() reads it, not in a route handler, and move the secret into registerAsync if it prints undefined.
Further reading: Vite server running but not working on localhost: 3 causes.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix error:0308010C digital envelope routines::unsupported
Find which part of your build still asks for MD4, apply the one-line workaround to unblock a deploy, then remove it with a webpack or react-scripts upgrade.
Fix Invalid options object: allowedHosts[0] with CRA proxy
Find which of the two triggers crashes npm start, pick a fix that still proxies your API calls, and avoid the http-proxy-middleware v3 path trap.
Vite server running but not working on localhost: 3 causes
Tell in one request whether Vite itself answered 404 or another server did, then move index.html, pin the host or free the port, and confirm it with curl.


