Skip to content
블로그로 돌아가기
보안

RS256 개인 키 형식 오류: 같은 메시지, 일곱 가지 원인

RS256 개인 키 형식 오류는 컨테이너 불일치, 헤더 들여쓰기, 공개 키 혼동에 모두 같은 DECODER 메시지를 냅니다. PKCS#1·PKCS#8 실측 해결표와 온라인 키 생성기.

13분 소요

RS256 개인 키 형식 오류: 같은 메시지, 일곱 가지 원인

RS256 개인 키 형식 오류는 원인을 스스로 밝히는 일이 거의 없습니다. Node v25.8.2에서는 아래 실수 가운데 무엇을 저질러도 똑같은 출력이 나옵니다:

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

서로 아무 관련 없는 다섯 가지가 이 오류를 촉발합니다. PEM 키가 와야 할 자리에 들어온 OpenSSH 컨테이너, 들여쓰기된 -----BEGIN 줄, 서명하는 쪽에 건네진 공개 키, 아무도 되돌리지 않은 문자 그대로의 \n 시퀀스, 그리고 전송 도중 줄바꿈이 지워진 파일입니다. 2절의 실측 목록은 일곱 개까지 이어집니다. 매번 똑같은 한 줄이고, 그래서 오류 문구를 검색하면 남의 원인을 다루는 남의 스레드에 가 닿습니다.

먼저 문제를 반으로 가르세요:

  • 라이브러리가 키 객체를 끝내 얻지 못했습니다. 이 글에 머무르세요.
  • 라이브러리가 키를 읽어 들인 뒤에 invalid signature라고 했습니다. 다른 실패이고 원인도 다릅니다. JWT invalid signature 오류: 모든 원인과 해결 방법으로 가세요.

첫 번째 경우라면 30초짜리 선별부터 하세요:

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

이 명령이 오류를 내면 파일 자체가 문제이고, 3절부터 6절까지가 그것을 찾아냅니다. 성공하면 OpenSSL은 컨테이너를 이해했다는 뜻이므로 문제는 라이브러리 쪽이거나 라이브러리에 건넨 값 쪽이며, 그쪽은 4절과 7절에서 다룹니다.

아래 내용은 모두 2026-08-11에 OpenSSL 3.6.2 7 Apr 2026, Node v25.8.2, Go go1.26.1 darwin/arm64, Java 1.8.0_162에서 측정한 것입니다. 실행이 아니라 소스 코드를 읽어서 얻은 주장은 본문에 그렇게 밝혀 두었습니다.

1. 먼저 어느 쪽 실패인지부터 가려내세요

경계선은 키 객체가 한 번이라도 존재했는가입니다.

파싱 단계의 실패는 암호 연산이 시작되기 전에 일어납니다. 라이브러리가 PEM을 읽고, 그것을 키로 변환하지 못한 채 예외를 던집니다. 서명된 것도 없고 검증된 것도 없으며, 지금 디버깅하고 있는 토큰은 애초에 생성된 적이 없습니다. 검증 단계의 실패는 정반대입니다. 키는 깔끔하게 읽혔고, 서명이 계산됐고, 그 값이 맞지 않았습니다. 이쪽은 서명 측과 검증 측 사이의 바이트 수준 불일치에서 나오며, invalid signature 안내서가 그 내용을 다룹니다.

둘을 구분하는 데는 스택 트레이스를 한 번 보는 것으로 충분합니다. 파싱 단계의 실패는 디코더나 키 스펙, ASN.1 구조를 언급합니다. 검증 단계의 실패는 서명을 언급합니다.

거부된 개인 키는 세 생태계에서 각각 이렇게 보입니다:

런타임테스트한 버전키를 읽어 들이지 못할 때의 메시지
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

세 메시지의 친절함은 편차가 큽니다. Go는 대신 호출해야 할 함수를 정확히 알려 줍니다. Java는 “algid”와 “sequence”를 꺼내 놓고, 그것이 키의 컨테이너가 틀렸다는 뜻이라는 점은 알아서 짚어 내라고 합니다. Node는 쓸 만한 말을 아무것도 하지 않습니다.

지금 쫓고 있는 토큰이 정말 RS256인지 확신이 서지 않는다면, 더 들어가기 전에 JWT 디코더에 붙여 넣고 헤더의 alg를 읽어 보세요. HS256 헤더라면 필요한 것은 키 쌍이 아니라 공유 비밀 키이고, 이 글의 모든 증상이 엉뚱한 곳을 가리키게 됩니다.

2. 오류 문구에서 근본 원인으로: RS256 개인 키 형식 오류 조회표

지금 나온 문자열을 정확히 찾으세요. 오른쪽 칸이 다음 행선지입니다.

오류 문구어디서 나오는가실제 의미
error:1E08010C:DECODER routines::unsupportedNode v25.8.2아래에 정리한 일곱 가지 원인
error:07880109:common libcrypto routines::interrupted or cancelledNode v25.8.2키가 암호화되어 있는데 암호를 넘기지 않았습니다
x509: failed to parse private key (use ParsePKCS8PrivateKey instead for this key format)Go 1.26.1PKCS#8 파일에 ParsePKCS1PrivateKey를 호출했습니다
x509: failed to parse private key (use ParsePKCS1PrivateKey instead for this key format)Go 1.26.1PKCS#1 파일에 ParsePKCS8PrivateKey를 호출했습니다
asn1: structure error: tags don't match (16 vs {class:0 tag:6 ...})Go 1.26.1첫 PEM 블록이 키가 아니라 EC PARAMETERS입니다
algid parse error, not a sequenceJava 1.8.0_162PKCS#1을 PKCS8EncodedKeySpec에 건넸습니다
secretOrPrivateKey must have a valuejsonwebtoken, 소스 코드상키 인자가 falsy이고 algnone이 아닙니다
secretOrPrivateKey is not valid key materialjsonwebtoken, 소스 코드상개인 키로도 비밀 키로도 구성하지 못했습니다
secretOrPrivateKey must be a symmetric key when using ${header.alg}jsonwebtoken, 소스 코드상algHS로 시작하는데 키가 비밀 키가 아닙니다
secretOrPrivateKey must be an asymmetric key when using ${header.alg}jsonwebtoken, 소스 코드상algRS, PS, ES에 해당하는데 키가 개인 키가 아닙니다
secretOrPrivateKey has a minimum key size of 2048 bits for ${header.alg}jsonwebtoken, 소스 코드상RS 또는 PS인데 키가 2048비트 미만이고 allowInsecureKeySizes가 꺼져 있습니다

secretOrPrivateKey 문자열 다섯 개는 jsonwebtoken master 브랜치의 sign.js를 읽어서 가져온 것이며 로컬에서 실행한 것이 아닙니다. 따라서 촉발 조건은 이 컴퓨터에서 재현한 결과가 아니라 소스 코드가 그렇게 말하고 있다는 뜻으로 받아들이세요. ${header.alg} 부분은 그 소스의 템플릿 자리 표시자이고, 실행 시점에는 그 자리에 실제 알고리즘 이름이 찍힙니다. 중괄호까지 그대로 넣어 검색하면 아무것도 나오지 않는 이유가 이것입니다.

DECODER routines::unsupported를 일으키는 일곱 가지 경로

일곱 가지 모두 Node v25.8.2의 crypto.createPrivateKey()에 대해 재현했고, 일곱 가지 모두 같은 코드와 같은 메시지를 냈습니다:

  1. OpenSSH 컨테이너입니다. 파일이 -----BEGIN OPENSSH PRIVATE KEY-----로 시작하며, 애초에 PEM 키 구조가 아닙니다.
  2. 들여쓰기된 -----BEGIN 줄, 또는 들여쓰기된 -----END 줄입니다. 본문 줄은 해당하지 않으며, 정확한 경계는 5절에 있습니다.
  3. PEM 전체 앞에 붙은 공백 문자입니다. 앞에 붙은 빈 줄은 괜찮지만 앞에 붙은 공백 하나는 안 됩니다.
  4. 줄바꿈이 통째로 사라져 헤더와 base64와 푸터가 한 줄에 붙어 버린 경우입니다.
  5. 개인 키가 와야 할 자리에 들어온 공개 키입니다.
  6. 이스케이프가 풀리지 않은 문자 그대로의 백슬래시-n 시퀀스입니다. 한 줄짜리 환경 변수가 만들어 내는 바로 그 형태입니다.
  7. 대시 개수가 틀린 구분선, 또는 소문자로 쓴 begin/end입니다.

이 가운데 둘은 컨테이너 문제, 넷은 텍스트 훼손 문제, 하나는 단순한 착각입니다. 메시지는 어느 쪽인지 알려 주지 못하므로, 읽어서 알아내는 것보다 하나씩 지워 나가는 편이 빠릅니다.

Node가 받아들이는 것들, 탐색 범위는 이쪽이 더 빨리 좁혀집니다

반대쪽 목록이 더 쓸모 있습니다. 여기 실린 항목은 하나하나가 즉시 버릴 수 있는 가설이기 때문입니다. Node v25.8.2에서 crypto.createPrivateKey()는 다음을 모두 군말 없이 받아들였습니다:

  • PKCS#1과 PKCS#8 개인 키
  • EC SEC1 개인 키
  • CRLF 줄바꿈
  • 마지막 줄바꿈 없음
  • 접히지 않고 한 줄로 이어진 base64 본문
  • 들여쓰기된 본문 줄
  • PEM 앞의 빈 줄
  • UTF-8 BOM. '' + pem 형태와 0xEF 0xBB 0xBF로 시작하는 Buffer 형태 둘 다
  • PKCS#8 본문에 씌워진 PKCS#1 헤더

마지막 항목은 잠깐 짚고 갈 만합니다. 디코더는 base64 안쪽의 DER 구조를 읽고 바깥쪽 라벨은 무시합니다. 그래서 PKCS#8 내용 위에 BEGIN RSA PRIVATE KEY라고 적힌 파일도 그대로 읽힙니다. 알아 두면 쓸모 있고, 3절에 대한 경고이기도 합니다. 헤더 줄은 힌트일 뿐 보증이 아닙니다.

3. PEM 헤더 줄: 지금 손에 있는 것이 어떤 컨테이너인가

모든 PEM은 첫 줄에서 자기가 무엇인지 밝힙니다. 아래는 OpenSSL 3.6.2가 쓰는 헤더 값입니다:

내용첫 줄
PKCS#8 개인 키-----BEGIN PRIVATE KEY-----
PKCS#1 개인 키-----BEGIN RSA PRIVATE KEY-----
암호화된 개인 키-----BEGIN ENCRYPTED PRIVATE KEY-----
OpenSSH 개인 키-----BEGIN OPENSSH PRIVATE KEY-----
EC SEC1 개인 키-----BEGIN EC PARAMETERS-----, 그다음 두 번째 블록 -----BEGIN EC PRIVATE KEY-----
SPKI 공개 키-----BEGIN PUBLIC KEY-----
PKCS#1 공개 키-----BEGIN RSA PUBLIC KEY-----
Ed25519 개인 키-----BEGIN PRIVATE KEY-----, 그리고 파일 전체가 세 줄

그래서 head -1 key.pem은 어떤 조사에서든 첫 번째 질문에 답해 줍니다. 못 박아 둘 것이 세 가지 있습니다.

ENCRYPTED PRIVATE KEY는 형식 오류가 아닙니다. 넘기는 것을 잊은 암호입니다. Node는 이것만 다르게 보고하는데, ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLEDerror:07880109:common libcrypto routines::interrupted or cancelled가 나옵니다. 라이브러리가 암호를 요구했는데 아무것도 받지 못했기 때문입니다. 이 메시지를 DECODER 쪽 메시지와 섞지 마세요. 둘은 서로 아무 관계가 없습니다.

OPENSSH PRIVATE KEY는 다른 세계입니다. OpenSSH는 자체 컨테이너를 씁니다. PEM처럼 생긴 구분선 사이에 들어앉아 있지만 PKCS#1도 PKCS#8도 아닙니다. Node는 이것을 즉시 거부하고, Go의 crypto/x509 파서들과 JDK의 PKCS8EncodedKeySpec도 마찬가지입니다. JWT 서명 키가 ssh-keygen에서 나왔다면 그것이 버그입니다.

EC SEC1 파일에는 블록이 두 개 있습니다. openssl ecparam -genkeyEC PARAMETERS 블록을 먼저 쓰고 개인 키를 두 번째로 씁니다. 첫 PEM 블록만 읽는 코드는 파라미터를 집어 들고, 그 사실을 알려 주지 않은 채 실패합니다. 그 실패의 Go 버전은 4절에 있습니다.

헤더는 라벨일 뿐이므로 반대 방향의 확인도 중요합니다. 헤더가 말하는 것과 DER이 말하는 것이 다른 파일은 DER을 따라 파싱됩니다. head -1 읽기는 OpenSSL에서 곧바로 나온 파일에는 믿을 만하고, 사람이나 위키 페이지나 문자열 치환을 하는 스크립트를 거쳐 온 파일에는 믿을 수 없습니다.

4. 어느 라이브러리가 무엇을 받는가: 세 생태계의 PKCS#1과 PKCS#8

부서 간 형식 논쟁 대부분을 설명해 주는 매트릭스입니다. 각 줄은 이 글 첫머리에 적은 버전에서 측정했습니다.

라이브러리PKCS#1PKCS#8OpenSSH오류가 스스로를 설명하는가?
Node crypto아니요아니요. 원인은 여럿인데 DECODER routines::unsupported 하나뿐
Go crypto/x509예, 전용 함수예, 전용 함수아니요예. 어느 함수로 갈아타야 하는지 이름을 알려 줍니다
Java 표준 라이브러리아니요아니요아니요. algid parse error, not a sequence는 적극적으로 오해를 부릅니다

칸을 읽어 내려가면 논쟁은 저절로 정리됩니다. Node 서비스와 Java 서비스가 키 파일 하나를 공유하는 구성은 그 키가 PKCS#1이 되는 순간까지만 잘 굴러갑니다. 그 순간부터 Node는 계속 서명하고 Java는 ASN.1 시퀀스 이야기를 하는 메시지를 던집니다. 다른 서비스의 운영 환경에서 멀쩡히 돌아가는 키이니, 아무도 키를 의심하지 않습니다.

Node. 설정할 것이 없습니다. 컨테이너가 PKCS#1이든 PKCS#8이든 createPrivateKey()는 받아들입니다. 그런데도 예외가 났다면, 형식이 아니라 2절의 일곱 가지 원인에 시간을 쓰세요.

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);
}

손으로 만든 사본이 아니라 애플리케이션이 실제로 읽어 들이는 파일을 대상으로 실행하세요. 그러면 catch 분기가 2절에서 찾아볼 수 있는 코드와 메시지 쌍을 출력합니다.

Go. 컨테이너 둘에 함수 둘이고, 잘못된 쪽을 호출하는 것이 Go에서 가장 흔한 실패입니다. 메시지가 어느 쪽을 써야 하는지 알려 주므로 수정은 기계적입니다. 순서대로 둘 다 시도하면 판단할 일 자체가 사라집니다:

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
}

다만 그 전에 EC 함정을 처리해야 합니다. openssl ecparam -genkey가 쓴 파일에 대해 pem.DecodeTypeEC PARAMETERS인 블록을 반환하고, 세 파싱 함수 모두 여기서 다음을 내며 실패합니다:

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

이 메시지는 PEM 블록을 한 번도 언급하지 않으므로 보통은 키를 의심하게 됩니다. 그러지 말고 파라미터 블록을 건너뛰세요:

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

아니면 파일을 쓰는 ecparam 명령에 -noout을 붙여서 여분의 블록이 생기지 않게 하세요.

Java. 표준 라이브러리는 PKCS#8만 읽고 그 외에는 읽지 않습니다. Java 1.8.0_162에서 PKCS8EncodedKeySpec에 PKCS#1 키를 넣으면 이렇게 됩니다:

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

“algid”는 알고리즘 식별자이며, PKCS#8이 덧붙이고 PKCS#1에는 없는 필드입니다. 파서는 그것을 찾다가 RSA 모듈러스의 시작 부분을 만나고 포기했습니다. 이 메시지는 정확한 만큼이나 쓸모가 없습니다. 파일을 변환하면 오류는 사라집니다:

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

파일이 PKCS#8이 된 뒤의 Java 8 로드 경로는, 수정이 먹혔는지 확인하는 동안 테스트에 그대로 붙여 넣을 만큼 짧습니다:

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));

변환 대신 BouncyCastle을 추가하는 방법도 있습니다. 이쪽은 PKCS#1을 읽습니다. 변환은 명령 하나면 되고 의존성도 늘지 않으므로, 스택의 다른 곳에서 이미 그 라이브러리를 쓰고 있는 것이 아니라면 변환하세요.

5. 눈에 보이지 않는 문자들

여기서부터는 널리 퍼진 조언이 실측과 어긋납니다.

들여쓰기: 알려진 것과 정반대입니다

흔히 반복되는 지침은 PEM에서 구분선을 뺀 모든 줄이 0열에서 시작해야 한다고 말합니다. Node v25.8.2에서 테스트한 결과는 거꾸로입니다:

파일에 가한 변경결과
모든 줄을 들여씀실패
-----BEGIN 줄만 들여씀실패
-----END 줄만 들여씀실패
base64 본문 줄만 들여씀통과
PEM 전체 앞에 공백 하나실패
PEM 전체 앞에 빈 줄 하나통과

그러니 규칙은 이렇습니다. -----BEGIN 줄과 -----END 줄은 0열에서 시작해야 하고, 본문 줄의 들여쓰기는 아무 상관이 없습니다. 흔한 조언이 안심해도 된다고 말한 쪽이 실제로 깨지는 쪽입니다.

이 구분이 중요한 이유는 키가 들여쓰기되는 경로에 있습니다. PEM을 손으로 들여쓰는 사람은 없습니다. YAML 블록, Helm values 파일, Terraform heredoc, 클래스 본문 안의 파이썬 삼중 따옴표 문자열에 키를 붙여 넣을 때 벌어지는 일입니다. 이들 모두 구분선을 포함해 전체를 균일하게 들여쓰며, 그것이 위 표의 첫 줄입니다.

한 줄짜리 환경 변수에서 나온 문자 그대로의 백슬래시-n

PEM에는 줄바꿈이 있고, 환경 변수에는 실무상 줄바꿈이 없습니다. 그래서 키는 \n이 두 글자로 적힌 한 줄이 되어 .env 파일에 들어앉습니다. 그 파일을 읽는 도구가 무엇이든 백슬래시가 그대로 남은 문자열을 코드에 건네고, 파서는 구분선 뒤에 쓰레기가 붙은 것을 봅니다. Node에서 이것은 2절의 6번 원인이고, 메시지는 나머지 전부와 똑같은 DECODER routines::unsupported입니다.

쓰는 지점에서 되돌리세요:

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

여기에 덧붙일 안전장치가 둘 있습니다. 첫째, 문자열에 실제로 그 두 글자 시퀀스가 들어 있을 때만 치환을 적용하세요. 그러면 다른 로더를 거쳐 온 진짜 여러 줄 값은 손대지 않고 지나갑니다. 둘째, 플랫폼이 허용한다면 PEM 전체를 base64로 두는 편이 낫습니다. base64 한 줄로 저장했다가 시작할 때 디코딩하면 이스케이프 문제 자체가 없습니다.

BOM: Node에서는 무해하고, 다른 곳은 테스트하지 않았습니다

바이트 순서 표시(BOM)는 일부 윈도우 편집기가 UTF-8 파일 맨 앞에 쓰는 세 바이트 EF BB BF입니다. 키를 읽어 들이기 전에 이것을 제거하라는 조언이 흔합니다. Node v25.8.2에서는 아무 차이가 없었습니다. BOM이 앞에 붙은 PEM은 문자열로도, 그 세 바이트로 시작하는 Buffer로도 정상 파싱됐습니다.

이 결과의 범위는 조심해서 잡으세요. 이것은 Node v25.8.2에서만 측정한 것입니다. Java, 파이썬, 그 밖의 파서는 여기서 테스트하지 않았고, 이 파서들이 어떻게 동작하는지에 대해서는 이 글에 아무 내용도 없습니다. Java 서비스를 디버깅하고 있다면 BOM은 배제된 항목이 아니라 여전히 열려 있는 질문입니다.

BOM이 다른 것을 깨뜨리는 것은 사실이며, 키에 대한 그 조언도 아마 거기서 옮겨 왔을 것입니다. BOM이 앞에 붙은 문자열에 JSON.parse를 돌리면 실제로 실패하며, 이 잘 알려진 사례는 UTF-8 BOM: JSON 파싱 오류와 CSV 깨짐 해결에서 다룹니다. 그래서 JSON 설정 파일 안에 저장된 키 파일은 무엇이 키를 들여다보기 훨씬 전에 실패할 수 있습니다.

줄바꿈 문자, 끝 줄바꿈, 접는 폭

Node v25.8.2가 무혐의 처리한 용의자가 셋 더 있습니다:

  • CRLF 줄바꿈. 받아들입니다. 윈도우를 거쳐 온 키가 자동으로 망가지는 것은 아닙니다.
  • 마지막 줄바꿈 없음. 받아들입니다. 다만 이것은 파서마다 다릅니다. 끝 줄바꿈이 없는 PEM을 거부하는 파서가 있다고 알려져 있지만, Node는 받아들입니다. 다른 파서는 여기서 테스트하지 않았습니다.
  • 접히지 않은 본문. 받아들입니다. base64를 64자마다 접을 필요는 없습니다.

base64 본문을 실제로 깨뜨리는 것은 글자가 사라지거나 끼어들거나 다른 글자로 뒤바뀌는 경우이며, 이는 줄 접기와는 다른 실패입니다. 줄바꿈을 공백 문자로 만들어 버리는 채팅 클라이언트나 끝 글자를 먹는 텍스트 필드는 더 이상 디코딩되지 않는 본문을 남깁니다. 마우스로 끌지 말고 복사 버튼으로 복사하세요.

6. OpenSSL 3.x가 모르는 사이에 기본값을 바꿔 놓았습니다

OpenSSL 3.6.2 7 Apr 2026에서 측정한 결과입니다:

명령출력하는 컨테이너
openssl genrsa -out k.pem 2048PKCS#8, 헤더는 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.pemPKCS#1을 PKCS#8로 변환
openssl rsa -in b.pem -traditional -out a.pemPKCS#8을 PKCS#1로 변환

첫 두 줄을 다시 읽어 보세요. 이 빌드에서 genrsa는 기본으로 PKCS#8을 주고, BEGIN RSA PRIVATE KEY 파일을 내놓는 것은 -traditional 쪽입니다. 아직도 많은 안내서가 genrsa를 PKCS#1 명령으로, genpkey를 PKCS#8 명령으로 설명하며, 그대로 따라가면 생성하지도 않은 형식을 생성했다고 확신하게 됩니다.

실질적인 결과는 마이그레이션에서 드러납니다. Java를 쓰는 팀이 예전 OpenSSL을 돌리는 동료에게서 잘 동작하는 키를 받습니다. 아무 문제 없다가 여섯 달 뒤에 누군가 새 머신에서 키를 다시 생성합니다. 명령도 문서도 그대로인데 컨테이너가 달라졌습니다. 이제 JDK는 “똑같은 방식으로 생성한” 키에 algid parse error, not a sequence를 던집니다. 똑같지 않았습니다.

그러니 짐작하지 말고 확인하세요:

head -1 key.pem

출력 한 줄이면 3절의 표가 지금 손에 든 것이 무엇인지 알려 줍니다. 어떤 변환 명령이든 돌리기 전에 이것부터 하세요. PKCS#8 파일을 다시 PKCS#8로 변환하면 고친 것처럼 보이지만 실제로는 아무것도 달라지지 않기 때문입니다.

플래그를 아예 신경 쓰고 싶지 않다면, RSA 키 생성기가 같은 키 쌍에서 토글 하나로 두 컨테이너를 모두 출력합니다. 키 하나의 PKCS#1 사본과 PKCS#8 사본을 뽑아, 지금 거부하고 있는 라이브러리에 각각 넣어 볼 수 있습니다.

7. 멀쩡한 키를 거부하는 2048비트 하한

형식 문제처럼 보이지만 아닌 실패가 하나 있습니다. jsonwebtoken 소스의 sign.js는 이렇게 던집니다:

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

소스는 algRS 또는 PS 계열이고, 키가 2048비트 미만이며, allowInsecureKeySizes가 설정되지 않았을 때 이 예외를 던집니다. 이 검사는 런타임의 것이 아니라 라이브러리 자체의 것입니다. Node v25.8.2는 1024비트 RSA 키를 군말 없이 파싱하며, modulusLength: 1024는 다른 것과 다를 바 없는 키 객체를 내놓습니다. 그러니 키는 구조적으로 유효하고, 컨테이너도 맞고, OpenSSL도 읽어 내는데, 서명 호출만 실패합니다.

단서는 이 메시지가 숫자를 언급한다는 점입니다. 형식 오류는 디코더와 시퀀스와 키 재료를 이야기하고, 이 오류는 비트를 이야기합니다. 메시지에 크기가 보이면 PEM은 그만 들여다보세요.

1024비트 키는 대개 과거의 잔재입니다. 몇 년 전에 그때의 기본값으로 생성한 키이거나, 작은 키가 더 빨리 생성되기 때문에 아무도 다시 보지 않은 테스트 픽스처입니다. 해법은 allowInsecureKeySizes를 켜는 것이 아니라 2048비트 이상으로 새 키 쌍을 생성하는 것입니다. 그 옵션은 이유가 있어서 존재하는 검사를 꺼 버립니다.

크기만 남은 문제인지 확인하려면, JWT 인코더에서 올바른 크기의 새 키로 같은 페이로드에 서명해 보세요. 거기서는 토큰이 나오는데 정작 코드에서는 나오지 않는다면, 차이는 클레임이나 설정이 아니라 키에 있습니다.

8. RS256 키 실패에 쓰는 반복 가능한 절차

순서대로 실행하세요. 각 단계는 원인을 찾아내거나 분기 하나를 지웁니다.

  1. 헤더 줄을 읽으세요. head -1 key.pem을 실행하고 3절의 표와 맞춰 보세요. 컨테이너가 무엇인지, 파일이 암호화되어 있는지, 절대 동작하지 않을 OpenSSH 키인지가 여기서 나옵니다.
  2. OpenSSL에 파싱을 시켜 보세요. RSA라면 openssl rsa -in key.pem -noout -text | head -1, 알고리즘을 가리지 않으려면 openssl pkey -in key.pem -noout입니다. 성공은 바이트가 유효한 키이고 문제는 라이브러리 쪽에 있다는 뜻입니다. 실패는 파일이 손상됐다는 뜻이며, 4단계로 이어집니다.
  3. 매트릭스에서 쓰고 있는 라이브러리 줄을 확인하세요. 4절입니다. PKCS#1 파일을 든 Java이거나 잘못된 파싱 함수를 호출한 Go라면 여기서 끝납니다.
  4. 보이지 않는 문자를 보세요. head -c 32 key.pem | xxd는 앞쪽 바이트를 보여 주며, BOM과 앞선 공백과 들여쓰기된 구분선을 한눈에 잡아냅니다. 그다음 5절대로 -----BEGIN 줄과 -----END 줄이 0열에서 시작하는지 확인하세요.
  5. 알려진 정상 키로 이분 탐색하세요. RSA 키 생성기에서 새 키 쌍을 생성해 코드가 그것을 가리키게 하고, 오류가 살아남는지 보세요. 살아남으면 버그는 키 파일이 아니라 로딩 코드에 있고, 원본을 아무리 다시 정렬해도 소용없습니다. 사라지면 원본 파일이 문제이고, 이제 비교해 볼 정상 키가 손에 들어온 것입니다.
  6. 알고리즘과 크기는 마지막에 확인하세요. 헤더가 RS256이라고 되어 있는지 확인하고, 7절대로 키가 최소 2048비트인지 확인하세요.

5단계는 다들 건너뛰지만 시간은 가장 많이 아껴 줍니다. 깨끗한 기준 키 하나면 “키가 동작하지 않는다”는 막연한 말이 어느 쪽이 고장 났는가라는 예/아니요 질문으로 바뀝니다.

FAQ

BEGIN RSA PRIVATE KEY와 BEGIN PRIVATE KEY는 무엇이 다릅니까?

같은 RSA 키를 감싼 두 가지 컨테이너입니다. BEGIN RSA PRIVATE KEY는 PKCS#1이고 RSA 수치를 그대로 담습니다. BEGIN PRIVATE KEY는 PKCS#8이고 알고리즘 식별자를 덧붙이며, 그래서 ECDSA와 Ed25519 키도 담을 수 있습니다. 어느 쪽이 필요한지는 전적으로 라이브러리에 달려 있고, RSA 키 생성기는 둘 다 씁니다.

openssl genrsa가 생성한 형식이 왜 튜토리얼과 다릅니까?

기본값이 바뀌었기 때문입니다. OpenSSL 3.6.2에서 openssl genrsa -out k.pem 2048BEGIN PRIVATE KEY 헤더가 붙은 PKCS#8을 씁니다. 오래된 안내서가 설명하는 전통적인 PKCS#1 배치를 얻으려면 -traditional을 붙이세요. 지금 쓰는 빌드가 무엇을 내놓는지는 튜토리얼을 믿지 말고 출력에 head -1을 돌려서 확인하세요.

Java에서 algid parse error, not a sequence는 어떻게 고칩니까?

Java 1.8.0_162에서 그 메시지는 PKCS8EncodedKeySpec에 PKCS#1 키를 건넸다는 뜻입니다. 표준 라이브러리는 PKCS#1을 아예 읽지 못합니다. openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem으로 한 번 변환하거나, 프로젝트의 다른 곳에서 이미 쓰고 있다면 BouncyCastle을 추가하세요.

개인 키의 모든 줄이 0열에서 시작해야 합니까?

아니요. 흔한 조언이 거꾸로 알고 있습니다. Node v25.8.2에서 테스트한 결과, base64 본문 줄만 들여쓰면 정상 파싱되고 -----BEGIN 줄만 또는 -----END 줄만 들여쓰면 실패합니다. PEM 앞의 빈 줄은 받아들여지고, 앞선 공백 하나는 받아들여지지 않습니다.

.env 파일에 개인 키를 어떻게 저장해야 합니까?

\n 이스케이프가 들어간 따옴표 친 한 줄로 저장한 뒤 읽어 들일 때 .replace(/\\n/g, '\n')으로 되돌리거나, base64 한 줄로 저장한 뒤 시작할 때 디코딩하세요. 설정 로더가 틀릴 만한 이스케이프 규약이 아예 없으니 두 번째가 더 안전합니다.

RS256에 1024비트 키를 쓸 수 있습니까?

Node v25.8.2는 1024비트 RSA 키를 오류 없이 파싱하지만, jsonwebtoken 소스는 그것으로 서명하기를 거부합니다. allowInsecureKeySizes를 설정하지 않는 한 secretOrPrivateKey has a minimum key size of 2048 bits가 나옵니다. 대신 2048비트 키를 생성하세요. 이 메시지는 비트 수를 언급하므로 형식 문제와 구분됩니다.

개인 키 파일을 넘겼는데 비대칭 키가 필요하다는 RS256 개인 키 형식 오류가 나는 이유는 무엇입니까?

jsonwebtoken 소스에서 secretOrPrivateKey must be an asymmetric key when using ${header.alg}algRS, PS, ES이고 키가 개인 키가 아닐 때 발생합니다. 보통 그 값은 이전 설정에서 남은 HS256 방식의 비밀 키 문자열입니다. 무작위 문자열은 HS256과 JWT 비밀 키 생성기 쪽에 속하고, RS256에는 비밀 키가 아니라 키 쌍이 필요합니다.

결론

이 부류의 버그가 비싼 이유는 어려워서가 아닙니다. Node에서는 오류 문자열 하나가 일곱 가지 원인을 덮고, Java의 메시지는 진짜 답이 “컨테이너가 틀렸다”인데 ASN.1을 가리키며, 이 주제에서 가장 많이 반복되는 형식 관련 조언은 뒤집혀 있기 때문입니다. 읽어서 답에 도달할 수 없으니 대신 지워 나갑니다. 헤더 줄, OpenSSL 파싱, 라이브러리 매트릭스, 보이지 않는 문자, 알려진 정상 키 순서입니다.

반복을 막아 주는 습관은 둘입니다. 각 서비스가 어느 컨테이너를 요구하는지 비밀 저장소의 키 옆에 적어 두세요. 제약은 키가 아니라 라이브러리에 들어 있기 때문입니다. 그리고 알려진 정상 키 쌍을 대조군 용도로 개발 환경에 두세요. 그러면 어떤 키 실패든 첫 질문에 1분 안에 예/아니요로 답할 수 있습니다.

키가 제대로 읽힌 다음 이 키들을 어떻게 발급하고 교체하고 범위를 제한해야 하는가라는 더 넓은 질문은 JWT 보안 모범 사례: 웹 토큰 공격과 방어 (2026)를 참고하세요.

태그: jwt rsa pem openssl debugging security