Skip to content
Back to Blog
Security

RS256 Private Key Format Error: One Message, Seven Causes

One RS256 DECODER error covers a wrong container, an indented header and a public key. Tested fixes for PKCS#1, PKCS#8 and OpenSSH. Free online generator.

13 min read

RS256 Private Key Format Error: One Message, Seven Causes

An RS256 private key format error almost never names its own cause. On Node v25.8.2, every one of these mistakes produces the identical line:

code:    ERR_OSSL_UNSUPPORTED
message: error:1E08010C:DECODER routines::unsupported

Five unrelated things trigger it: an OpenSSH container where a PEM key was expected, an indented -----BEGIN line, a public key handed to a signer, literal \n sequences that nobody unescaped, and a file whose line breaks were stripped in transit. The tested list in Section 2 runs to seven. The line is the same every time, which is why searching the error text drops you into somebody else’s thread about somebody else’s cause.

First, split the problem in half:

Thirty-second triage for the first case:

openssl rsa -in key.pem -noout -text | head -1

If that errors, the file is the problem and Sections 3 through 6 will find it. If it succeeds, OpenSSL understood the container, and your problem is the library or what you handed it, which is Sections 4 and 7.

Everything below was measured on 2026-08-11 against OpenSSL 3.6.2 7 Apr 2026, Node v25.8.2, Go go1.26.1 darwin/arm64 and Java 1.8.0_162. Where a claim comes from reading source rather than running it, the text says so.

1. First, work out which failure you have

The dividing line is whether a key object ever existed. Parse-time failures happen before any cryptography runs: the library reads your PEM, fails to turn it into a key, and throws. Nothing was signed, and the token you are debugging was never produced. Verify-time failures are the opposite: the key loaded cleanly, a signature was computed, and it did not match. Those come from byte-level disagreements between signer and verifier, and the invalid signature guide covers them.

Telling them apart takes one look at the stack trace. A parse-time failure names a decoder, a key spec, or an ASN.1 structure. A verify-time failure names a signature.

A rejected private key looks like this in three ecosystems:

RuntimeVersion testedMessage when the key will not load
Node cryptov25.8.2error:1E08010C:DECODER routines::unsupported
Go crypto/x509go1.26.1 darwin/arm64x509: failed to parse private key (use ParsePKCS1PrivateKey instead for this key format)
Java PKCS8EncodedKeySpec1.8.0_162InvalidKeySpecException: java.security.InvalidKeyException: IOException : algid parse error, not a sequence

They are not equally helpful. Go tells you exactly which function to call instead. Java mentions an “algid” and a “sequence” and leaves you to work out that it means your key is in the wrong container. Node says nothing usable at all.

If you are not certain the token you are chasing is even RS256, paste it into the JWT decoder and read alg from the header before going further. An HS256 header means you need a shared secret, not a key pair, and every symptom in this article will point you the wrong way.

2. Error text to root cause: the RS256 private key format error lookup table

Find your exact string. The right-hand column is where to go next.

Error textWhere it comes fromWhat it actually means
error:1E08010C:DECODER routines::unsupportedNode v25.8.2Seven possible causes, listed below
error:07880109:common libcrypto routines::interrupted or cancelledNode v25.8.2The key is encrypted and you supplied no passphrase
x509: failed to parse private key (use ParsePKCS8PrivateKey instead for this key format)Go 1.26.1You called ParsePKCS1PrivateKey on a PKCS#8 file
x509: failed to parse private key (use ParsePKCS1PrivateKey instead for this key format)Go 1.26.1You called ParsePKCS8PrivateKey on a PKCS#1 file
asn1: structure error: tags don't match (16 vs {class:0 tag:6 ...})Go 1.26.1The first PEM block is EC PARAMETERS, not the key
algid parse error, not a sequenceJava 1.8.0_162PKCS#1 given to PKCS8EncodedKeySpec
secretOrPrivateKey must have a valuejsonwebtoken, in the sourceThe key argument is falsy and alg is not none
secretOrPrivateKey is not valid key materialjsonwebtoken, in the sourceNeither a private key nor a secret key could be built
secretOrPrivateKey must be a symmetric key when using ${header.alg}jsonwebtoken, in the sourcealg starts with HS but the key is not a secret
secretOrPrivateKey must be an asymmetric key when using ${header.alg}jsonwebtoken, in the sourcealg matches RS, PS or ES but the key is not private
secretOrPrivateKey has a minimum key size of 2048 bits for ${header.alg}jsonwebtoken, in the sourceRS or PS with a key under 2048 bits and allowInsecureKeySizes off

The five secretOrPrivateKey strings were read from sign.js on the jsonwebtoken master branch, not run locally, so treat the trigger conditions as what the source says rather than as something reproduced on this machine. The ${header.alg} part is a template placeholder in that source; at runtime you will see your own algorithm name there, which is why searching for the literal string with the braces finds nothing.

The seven ways to produce DECODER routines::unsupported

All seven were reproduced against crypto.createPrivateKey() on Node v25.8.2, and all seven gave the same code and message:

  1. An OpenSSH container. The file starts -----BEGIN OPENSSH PRIVATE KEY----- and is not a PEM key structure at all.
  2. An indented -----BEGIN line, or an indented -----END line. Body lines are exempt; Section 5 has the exact boundary.
  3. Whitespace before the whole PEM. A leading blank line is fine, a leading space is not.
  4. Newlines removed entirely, so the header, the base64 and the footer run together on one line.
  5. A public key where a private key was expected.
  6. Literal backslash-n sequences left unescaped, which is what a single-line environment variable turns into.
  7. A delimiter with the wrong number of dashes, or begin/end written in lowercase.

Two of those are container problems, four are text-mangling problems, and one is a plain mix-up. The message cannot tell you which, so the fastest route is elimination rather than reading.

What Node accepts, which narrows the search faster

The inverse list is more useful, because every item on it is a theory you can drop immediately. On Node v25.8.2, crypto.createPrivateKey() accepted all of the following without complaint:

  • PKCS#1 and PKCS#8 private keys
  • EC SEC1 private keys
  • CRLF line endings
  • A missing final newline
  • A base64 body on a single unwrapped line
  • Indented body lines
  • A blank line before the PEM
  • A UTF-8 BOM, both as '' + pem and as a Buffer beginning 0xEF 0xBB 0xBF
  • A PKCS#1 header wrapped around a PKCS#8 body

The last one has a consequence worth carrying forward. The decoder reads the DER structure inside the base64 and ignores the label on the outside, so a file that says BEGIN RSA PRIVATE KEY over PKCS#8 content loads anyway. That also qualifies Section 3: the header line is a hint rather than a guarantee.

3. The PEM header line: which container you are actually holding

Every PEM announces itself on line one. These are the header values written by OpenSSL 3.6.2:

ContentFirst line
PKCS#8 private key-----BEGIN PRIVATE KEY-----
PKCS#1 private key-----BEGIN RSA PRIVATE KEY-----
Encrypted private key-----BEGIN ENCRYPTED PRIVATE KEY-----
OpenSSH private key-----BEGIN OPENSSH PRIVATE KEY-----
EC SEC1 private key-----BEGIN EC PARAMETERS-----, then a second block -----BEGIN EC PRIVATE KEY-----
SPKI public key-----BEGIN PUBLIC KEY-----
PKCS#1 public key-----BEGIN RSA PUBLIC KEY-----
Ed25519 private key-----BEGIN PRIVATE KEY-----, and the whole file is three lines

So head -1 key.pem answers the first question of any investigation. Three details are worth pinning down.

ENCRYPTED PRIVATE KEY is not a format error. It is a passphrase you forgot to pass. Node reports it differently from everything else, with ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED and error:07880109:common libcrypto routines::interrupted or cancelled, because the library asked for a passphrase and got nothing back. Do not mix this message in with the DECODER one; they have nothing to do with each other.

OPENSSH PRIVATE KEY is a different world. OpenSSH writes its own container, which is not PKCS#1 or PKCS#8 despite sitting between PEM-looking delimiters. Node rejects it outright, and so do Go’s crypto/x509 parsers and the JDK’s PKCS8EncodedKeySpec. If your JWT signing key came out of ssh-keygen, that is your bug.

EC SEC1 files hold two blocks. openssl ecparam -genkey writes an EC PARAMETERS block first and the private key second. Anything that reads only the first PEM block gets the parameters and fails in a way that mentions neither. Section 4 has the Go version of that failure.

And because the header is only a label, the reverse check matters too: a file whose header says one thing and whose DER says another parses according to the DER. Reading head -1 is reliable for files that came straight out of OpenSSL and unreliable for files that passed through a human, a wiki page, or a script doing string replacement.

4. Which library accepts which: PKCS#1 vs PKCS#8 across three ecosystems

This matrix settles most cross-team format arguments. Each row is measured on the versions listed at the top of this article.

LibraryPKCS#1PKCS#8OpenSSHDoes the error explain itself?
Node cryptoYesYesNoNo. Many causes, one DECODER routines::unsupported
Go crypto/x509Yes, dedicated functionYes, dedicated functionNoYes. It names the function to switch to
Java standard libraryNoYesNoNo. algid parse error, not a sequence is actively misleading

Read the columns and the arguments resolve themselves. A Node service and a Java service sharing one key file works fine until the key is PKCS#1, at which point Node keeps signing and Java throws a message about ASN.1 sequences. Nobody suspects the key, because it demonstrably works in production on the other service.

Node. Nothing to configure. If the container is PKCS#1 or PKCS#8, createPrivateKey() takes it. When it does throw, spend your time on the seven causes in Section 2 rather than on the format.

const fs = require('node:fs');
const { createPrivateKey } = require('node:crypto');

try {
  const key = createPrivateKey(fs.readFileSync('key.pem'));
  console.log('parsed:', key.asymmetricKeyType);
} catch (err) {
  console.log(err.code, '/', err.message);
}

Run that against the file your application loads, not against a copy you made by hand, and the catch branch prints the code and message pair you can look up in Section 2.

Go. Two containers, two functions, and calling the wrong one is the most common Go failure. The message tells you which to use, so the fix is mechanical. Trying both in order removes the decision:

priv, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
	rsaKey, err2 := x509.ParsePKCS1PrivateKey(block.Bytes)
	if err2 != nil {
		log.Fatalf("neither container parsed: %v / %v", err, err2)
	}
	priv = rsaKey
}

The EC trap needs handling before that, though. Against a file from openssl ecparam -genkey, pem.Decode returns a block whose Type is EC PARAMETERS, and all three parse functions fail on it with:

asn1: structure error: tags don't match (16 vs {class:0 tag:6 ...})

That message never mentions PEM blocks, so the usual reaction is to suspect the key. Skip the parameters block instead:

block, rest := pem.Decode(pemBytes)
if block == nil {
	log.Fatal("no PEM block found")
}
if block.Type == "EC PARAMETERS" {
	block, _ = pem.Decode(rest)
}

Or avoid producing the extra block at all by adding -noout to the ecparam command that writes the file.

Java. The standard library reads PKCS#8 and nothing else. Feed PKCS8EncodedKeySpec a PKCS#1 key on Java 1.8.0_162 and you get:

InvalidKeySpecException: java.security.InvalidKeyException: IOException : algid parse error, not a sequence

“algid” is the algorithm identifier, the field PKCS#8 adds and PKCS#1 does not have. The parser looked for it, found the start of an RSA modulus, and gave up. The message is correct and useless in equal measure. Convert the file and the error disappears:

openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem

The working Java 8 load path, once the file is PKCS#8, is short enough to inline in a test while you confirm the fix:

String pem = new String(Files.readAllBytes(Paths.get("key.pem")), StandardCharsets.UTF_8)
        .replace("-----BEGIN PRIVATE KEY-----", "")
        .replace("-----END PRIVATE KEY-----", "")
        .replaceAll("\\s+", "");
byte[] der = Base64.getDecoder().decode(pem);
PrivateKey key = KeyFactory.getInstance("RSA")
        .generatePrivate(new PKCS8EncodedKeySpec(der));

The alternative to converting is adding BouncyCastle, which does read PKCS#1. Converting is one command and no dependency, so convert unless something else in your stack already needs the library.

5. The characters you cannot see

Most of the repeated advice about invisible characters in a PEM turns out to be wrong once you test it.

Indentation: the opposite of what you have been told

A widely repeated instruction says every line of a PEM except the delimiters must start at column zero. Tested on Node v25.8.2, that is backwards:

Change to the fileResult
Every line indentedFails
Only the -----BEGIN line indentedFails
Only the -----END line indentedFails
Only the base64 body lines indentedAccepted
A space before the whole PEMFails
A blank line before the whole PEMAccepted

So the rule is: the -----BEGIN and -----END lines must start at column zero, and indentation of the body lines does not matter. Exactly the two lines people are told they can indent are the two that break, and the lines they are told to align are the ones with slack.

This matters because of how private keys get indented in the first place. Nobody indents a PEM by hand. It happens when a key is pasted into a YAML block, a Helm values file, a Terraform heredoc, or a Python triple-quoted string inside a class body. Every one of those indents the whole thing uniformly, delimiters included, which is the first row of that table.

Literal backslash-n from a single-line environment variable

A PEM has line breaks and an environment variable, in practice, does not. So keys land in .env files as one line with \n written out as two characters. Whatever reads that file hands your code a string containing backslashes, and the parser sees a delimiter followed by garbage. On Node this is cause 6 from Section 2, with the same DECODER routines::unsupported message as everything else.

Undo it at the point of use:

const pem = process.env.PRIVATE_KEY.replace(/\\n/g, '\n');

Two guards are worth adding around that. First, only apply the replacement when the string actually contains the two-character sequence, so a genuinely multi-line value from a different loader passes through untouched. Second, prefer base64 for the whole PEM if your platform allows it: store one line of base64, decode it at startup, and there is no escaping question at all.

BOM: harmless on Node, and untested elsewhere

A byte order mark is three bytes, EF BB BF, that some Windows editors write at the start of a UTF-8 file. Advice to strip it before loading a key is common. On Node v25.8.2 it made no difference: a PEM prefixed with the BOM parsed successfully both as a string and as a Buffer starting with those three bytes.

Scope that result carefully. It was measured on Node v25.8.2 only. Java, Python and other parsers were not tested here, and nothing in this article says how they behave. If you are debugging a Java service, the BOM remains an open question rather than a ruled-out one.

The BOM does break other things, which is probably where the key advice came from by association. JSON.parse on a BOM-prefixed string is a real and well-documented failure, covered in the UTF-8 BOM JSON parse error guide. A key file stored inside a JSON config can therefore fail long before anything looks at the key.

Line endings, trailing newline, and wrap width

Three more suspects that Node v25.8.2 cleared:

  • CRLF line endings. Accepted. A key that travelled through Windows is not automatically broken.
  • A missing final newline. Accepted. This one is parser-specific: some parsers are said to reject a PEM without its trailing newline, and Node is not one of them. Other parsers were not tested here.
  • An unwrapped body. Accepted. The base64 does not need to be folded at 64 characters.

What does break a base64 body is a lost, inserted or substituted character, which is a different failure from wrapping. A chat client that turns a line break into a space, or a text field that eats a trailing character, produces a body that no longer decodes. Copy with a copy button rather than a mouse drag.

6. OpenSSL 3.x changed the default under you

Measured on OpenSSL 3.6.2 7 Apr 2026:

CommandContainer it writes
openssl genrsa -out k.pem 2048PKCS#8, header BEGIN PRIVATE KEY
openssl genrsa -traditional -out k.pem 2048PKCS#1
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048PKCS#8
openssl genpkey -algorithm ED25519PKCS#8
openssl pkcs8 -topk8 -nocrypt -in a.pem -out b.pemConverts PKCS#1 to PKCS#8
openssl rsa -in b.pem -traditional -out a.pemConverts PKCS#8 to PKCS#1

Read the first two rows again. genrsa gives you PKCS#8 by default on this build, and -traditional is what produces the BEGIN RSA PRIVATE KEY file. Plenty of guides still describe genrsa as the PKCS#1 command and genpkey as the PKCS#8 one, and following them will leave you certain you generated a format you did not.

The practical consequence shows up in migrations. A team on Java gets a working key from a colleague running an older OpenSSL, everything is fine, and six months later somebody regenerates the key on a fresh machine. Same command, same documentation, different container, and now the JDK throws algid parse error, not a sequence at a key that “was generated exactly the same way”. It was not.

So check rather than assume:

head -1 key.pem

One line of output, and Section 3’s table tells you what you are holding. Do this before running any conversion command, because converting a PKCS#8 file to PKCS#8 is a no-op that looks like a fix and fixes nothing.

If you would rather not think about the flags at all, the RSA key generator emits both containers from the same key pair with a toggle, so you can produce a PKCS#1 and a PKCS#8 copy of one key and try each against the library that is refusing you.

7. The 2048-bit floor that rejects a perfectly valid key

One failure looks like a format problem and is not. In the jsonwebtoken source, sign.js throws:

secretOrPrivateKey has a minimum key size of 2048 bits for ${header.alg}

The source raises it when alg is an RS or PS algorithm, the key is under 2048 bits, and allowInsecureKeySizes has not been set. That check is the library’s own, not the runtime’s. Node v25.8.2 parses a 1024-bit RSA key without complaint; modulusLength: 1024 produces a key object like any other. So the key is structurally valid, the container is right, OpenSSL reads it, and the signing call still fails.

The tell is that this message names a number. Format errors talk about decoders, sequences and key material; this one talks about bits. If you see a size in the message, stop looking at the PEM.

Where 1024-bit keys come from is usually history: a key generated years ago against a default that has since moved, or a test fixture nobody revisited because small keys generate faster. The fix is to generate a new pair at 2048 bits or above rather than to reach for the escape hatch, since the escape hatch turns off a check that exists for a reason.

To confirm the size is the only remaining problem, sign the same payload with a fresh key of the right size in the JWT encoder. If that produces a token and your own code does not, the difference is your key, not your claims or your configuration.

8. A repeatable workflow for RS256 key failures

Run these in order. Each step either finds the cause or removes a branch.

  1. Read the header line. head -1 key.pem, then match it against the table in Section 3. This tells you the container, whether the file is encrypted, and whether it is an OpenSSH key that will never work.
  2. Ask OpenSSL to parse it. openssl rsa -in key.pem -noout -text | head -1 for RSA, or openssl pkey -in key.pem -noout for any algorithm. Success means the bytes are a valid key and the problem is on the library side. Failure means the file is damaged and you continue to step 4.
  3. Check your library’s row in the matrix. Section 4. If you are on Java with a PKCS#1 file, or on Go calling the wrong parse function, you are done here.
  4. Look at the invisible characters. head -c 32 key.pem | xxd shows the first bytes, which catches a BOM, a leading space and an indented delimiter in one glance. Then confirm the -----BEGIN and -----END lines start at column zero, per Section 5.
  5. Bisect with a known-good key. Generate a fresh pair in the RSA key generator, point your code at it, and see whether the error survives. If it does, the bug is in your loading code rather than in the key file, and no amount of reformatting the original will help. If it disappears, the original file is at fault and you now have a working key to diff against.
  6. Check the algorithm and the size last. Confirm the header says RS256, and confirm the key is at least 2048 bits, per Section 7.

Step 5 is the one people skip and the one that saves the most time. A clean reference key converts a vague “the key does not work” into a binary answer about which side is broken.

FAQ

What is the difference between BEGIN RSA PRIVATE KEY and BEGIN PRIVATE KEY?

They are two containers around the same RSA key. BEGIN RSA PRIVATE KEY is PKCS#1 and holds the RSA numbers directly; BEGIN PRIVATE KEY is PKCS#8 and adds an algorithm identifier, which is why it can also carry ECDSA and Ed25519 keys. Which one you need depends entirely on the library, and the RSA key generator writes either.

Why does openssl genrsa produce a different format than the tutorial shows?

Because the default moved. On OpenSSL 3.6.2, openssl genrsa -out k.pem 2048 writes PKCS#8 with a BEGIN PRIVATE KEY header. To get the traditional PKCS#1 layout that older guides describe, add -traditional. Run head -1 on the output rather than trusting any tutorial about what your build produces.

How do I fix algid parse error, not a sequence in Java?

That message on Java 1.8.0_162 means you gave PKCS8EncodedKeySpec a PKCS#1 key. The standard library does not read PKCS#1 at all. Convert once with openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem, or add BouncyCastle if something else in the project already needs it.

Does every line of a private key need to start at column zero?

No, and the common advice has it backwards. Tested on Node v25.8.2, indenting only the base64 body lines parses fine, while indenting only the -----BEGIN line or only the -----END line fails. A leading blank line before the PEM is accepted; a leading space is not.

How should a private key be stored in a .env file?

Either as a quoted single line with \n escapes that you undo at load time with .replace(/\\n/g, '\n'), or as one line of base64 that you decode at startup. The second is safer because there is no escaping convention for a config loader to get wrong.

Can I use a 1024-bit key with RS256?

Node v25.8.2 parses a 1024-bit RSA key without error, but the jsonwebtoken source refuses to sign with it: secretOrPrivateKey has a minimum key size of 2048 bits, unless allowInsecureKeySizes is set. Generate a 2048-bit key instead. The message names a bit count, which is how you tell it apart from a format problem.

Why do I get an RS256 private key format error saying it needs an asymmetric key when I passed a private key file?

In the jsonwebtoken source, secretOrPrivateKey must be an asymmetric key when using ${header.alg} fires when alg is RS, PS or ES and the key is not a private key. Usually the value is an HS256-style secret string left over from an earlier configuration. A random string belongs with HS256 and the JWT secret generator; RS256 needs a key pair, not a secret.

Conclusion

This class of bug is expensive for reasons unrelated to difficulty. One error string covers seven causes on Node, Java’s message points at ASN.1 when the real answer is “wrong container”, and the most repeated piece of formatting advice on the subject is inverted. You cannot read your way to the answer, so you eliminate instead: header line, OpenSSL parse, library matrix, invisible characters, known-good key.

Two habits prevent the repeat. Write down which container each service requires, next to the key in your secret store, because the constraint lives in the library and not in the key. And keep a known-good key pair in your development environment purely as a control, so the first question about any key failure gets a yes-or-no answer in a minute.

For the wider question of how these keys should be issued, rotated and scoped once they load correctly, see JWT security best practices.

Tags: jwt rsa pem openssl debugging security

Related Articles

View all articles