메시지에 서명하면 사용자가 해당 지갑을 제어하고 있음을 증명할 수 있으며, 트랜잭션도, 수수료도, 네트워크 호출도 필요하지 않습니다. 앱은 서명된 메시지를 사용해 사용자를 로그인시키거나, 사용자가 약관에 동의했음을 기록하거나, 지갑을 기존 계정에 연결하거나, 주문 접수와 같이 오프체인에서 이루어지는 작업을 승인합니다.
Solana 지갑은 두 가지 방식으로 메시지에 서명합니다.
- 오프체인 메시지(
solana:signOffchainMessage)는 텍스트를 오프체인 메시지 서명(OCMS) 사양에 정의된 표준 형식으로 감쌉니다. 이 형식은 트랜잭션으로 오인될 수 없고, 서명해야 하는 계정을 명시하며, 하드웨어 지갑이 서명하는 형식입니다. - 원시 메시지(
solana:signMessage)는 앱이 전달한 바이트를 접두사나 서명자 선언 없이 그대로 서명합니다. 대부분의 소프트웨어 지갑이 이를 지원하지만, 바이트만으로는 트랜잭션이 아닌 메시지라는 점을 구분할 수 없습니다.
지갑이 지원하는 경우에는 오프체인 메시지를 사용하고, 지원하지 않는 경우에는 원시 메시지로 대체하세요. 이 가이드의 나머지 부분에서는 로그인을 예시로 설명하지만, 서명 및 검증 단계는 모든 메시지에 동일하게 적용됩니다.
메시지 형식 선택
| 오프체인 메시지 | 원시 메시지 | 트랜잭션 | |
|---|---|---|---|
| 지갑 기능 | signOffchainMessage | signMessage | signTransaction |
| 하드웨어 지갑 지원 | 기기에 따라 다름 | 아니요 | 예 |
| 사용자가 읽을 수 있음 | 예 | 소프트웨어 지갑만 해당 | 기기에 따라 다름 |
| 필수 서명자 선언 | 예 | 아니요 | 예 |
| RPC 호출 및 수수료 | 없음 | 없음 | 블록해시, 전송, 수수료, 확인 |
왜 트랜잭션을 사용하지 않나요?
예전에는 하드웨어 지갑으로 읽을 수 있는 메시지에 서명할 방법이 없었습니다. 그래서 많은 앱이 이러한 사용자에게 아무 동작도 하지 않는 트랜잭션에 서명하도록 요청했습니다. 이 방식도 동작은 하지만 지연 시간, 비용, 위험이 늘어납니다.
- 네트워크 수수료. 트랜잭션은 아무 동작도 하지 않더라도 수수료를 지불합니다.
- 여러 번의 RPC 호출. 앱이 블록해시를 가져오고, 시뮬레이션하고, 전송한 다음 확인 여부를 폴링해야 합니다.
- 추가 지연 시간. 이러한 호출은 각각 왕복 통신이므로, 사용자가 로그인하기까지 시간이 더 걸립니다.
- 읽을 수 있는 내용이 없음. 사용자는 무엇에 동의하는지 설명하는 문구가 아니라 트랜잭션 바이트를 승인하게 됩니다.
- 행동 측면의 위험. 로그인하기 위해 트랜잭션에 서명하도록 요청하면, 사용자가 악의적인 트랜잭션에도 속아서 서명하기 쉬워집니다.
오프체인 메시지는 이 모든 과정을 건너뜁니다. 왕복 통신은 사용자의 서버와만 이루어집니다.
지갑이 서명하는 내용
오프체인 메시지는 고정된 서명 도메인인 \xffsolana offchain으로 시작합니다.
유효한 트랜잭션은 이 바이트로 시작하지 않으므로, 서명된 메시지를 트랜잭션으로 사용할 수
없습니다. 이어서 형식 버전 1은 필수 서명자를 나열하고, 길이 제한 없이 UTF-8 텍스트로
메시지를 담습니다.
지갑은 이 바이트를 구성해 서명한 뒤, 서명과 함께 반환합니다. 사양에서는 레거시 버전 0을 포함한 전체 형식을 다룹니다.
로그인 방식 선택
아직 모든 지갑이 오프체인 메시지를 지원하는 것은 아닙니다. 서명을 요청하기 전에 연결된 계정의 기능을 확인하고, 해당 기능이 없으면 대체 방식을 사용하세요.
- 계정이 메시지 버전 1의
solana:signOffchainMessage를 지원하면 오프체인 메시지에 서명합니다. - 그렇지 않으면 같은 챌린지를
solana:signMessage로 서명합니다. 대부분의 소프트웨어 지갑이 이를 지원합니다. - 메시지를 지원하지 않는 하드웨어 지갑 사용자에게는 "하드웨어 지갑을 사용 중입니다" 토글과 같은 명시적인 선택 옵션을 제공하고, 이 경우 로그인 트랜잭션에 대신 서명하도록 하세요. 기본값으로 이 경로를 사용하게 하지는 마세요.
브라우저에서 메시지 서명하기
Kit과 함께 Wallet Standard 패키지를 설치합니다.
$npm install @solana/kit @solana/wallet-standard-features @wallet-standard/ui @wallet-standard/ui-features @wallet-standard/ui-registry
이 헬퍼들은 React 가이드의 useConnectedWallet이 반환하는
account와 같은 UiWalletAccount를 받습니다.
import {SolanaSignOffchainMessage,type SolanaSignOffchainMessageFeature} from "@solana/wallet-standard-features";import type { UiWalletAccount } from "@wallet-standard/ui";import { getWalletAccountFeature } from "@wallet-standard/ui-features";import { getWalletAccountForUiWalletAccount } from "@wallet-standard/ui-registry";type OffchainMessageFeature =SolanaSignOffchainMessageFeature[typeof SolanaSignOffchainMessage];export function supportsOffchainMessages(account: UiWalletAccount) {if (!account.features.includes(SolanaSignOffchainMessage)) return false;const feature = getWalletAccountFeature(account,SolanaSignOffchainMessage) as OffchainMessageFeature;return feature.supportedMessageVersions.includes(1);}export async function signOffchainMessage(account: UiWalletAccount,message: string) {const feature = getWalletAccountFeature(account,SolanaSignOffchainMessage) as OffchainMessageFeature;const [output] = await feature.signOffchainMessage({messageVersion: 1,account: getWalletAccountForUiWalletAccount(account),message,requiredSigners: [account.publicKey]});return output;}
signOffchainMessage는 지갑이 서명한 전체 바이트인 signedOffchainMessage와
signature를 반환합니다. 두 값을 모두 Base64로 인코딩해 지갑 주소와 함께 서버로 보내세요.
작은 화면에서도 읽기 좋게 챌린지를 작성하세요. 앱 이름과 도메인을 밝히고, 서명이 어떤 의미인지 설명하며, 서버에서 발급한 일회용 논스와 발급 시각을 포함하세요.
swap.app wants you to sign in with your Solana account.Signing in costs nothing and does not submit a transaction.Nonce: 8f3a1c9eIssued at: 2026-09-29T17:04:00Z
서버에서 서명 검증하기
검증은 로컬에서 수행하는 서명 확인이며 RPC 연결이 필요하지 않습니다. 지갑이 서명한 바이트를 디코딩하고, 발급한 챌린지와 일치하는지 확인한 다음 서명을 검사하세요.
import {address,assertOffchainMessageV1Equal,getBase64Encoder,getOffchainMessageDecoder,verifyOffchainMessageEnvelope,type OffchainMessageBytes,type SignatureBytes} from "@solana/kit";export async function verifySignIn(input: {walletAddress: string;expectedMessage: string;signedOffchainMessage: string;signature: string;}) {const signer = address(input.walletAddress);const base64 = getBase64Encoder();const content = base64.encode(input.signedOffchainMessage) as OffchainMessageBytes;const signature = base64.encode(input.signature) as SignatureBytes;const received = getOffchainMessageDecoder().decode(content);if (received.version !== 1) {throw new Error(`Expected a v1 message, got v${received.version}`);}assertOffchainMessageV1Equal(received, {version: 1,requiredSignatories: [{ address: signer }],content: input.expectedMessage});await verifyOffchainMessageEnvelope({content,signatures: { [signer]: signature }});}
assertOffchainMessageV1Equal은 내용이나 서명자가 예상과 다르면 예외를 발생시킵니다.
verifyOffchainMessageEnvelope는 서명이 없거나 유효하지 않으면 예외를 발생시킵니다.
클라이언트가 다시 보낸 사본을 신뢰하지 말고, 서버에서 논스로 expectedMessage를 조회하세요.
검증이 완료되면 논스를 소진 처리하여 같은 서명으로 두 번 로그인할 수 없도록 하세요.
원시 메시지 검증하기
signMessage 서명은 챌린지 바이트 자체를 대상으로 하므로, 클라이언트는 서명과 지갑 주소만
보내면 됩니다. 발급한 챌린지에 대해 서명을 확인하세요.
import {address,getBase64Encoder,getPublicKeyFromAddress,getUtf8Encoder,signatureBytes,verifySignature} from "@solana/kit";export async function verifyRawSignIn(input: {walletAddress: string;expectedMessage: string;signature: string;}) {const publicKey = await getPublicKeyFromAddress(address(input.walletAddress));const signature = signatureBytes(getBase64Encoder().encode(input.signature));const message = getUtf8Encoder().encode(input.expectedMessage);if (!(await verifySignature(publicKey, signature, message))) {throw new Error("Invalid signature");}}
동일한 논스 규칙이 적용됩니다. 논스로 expectedMessage를 조회하고, 검증이 완료되면 논스를 소진 처리하세요.
@solana/kit은 이 헬퍼들을 @solana/offchain-messages에서 다시 내보냅니다.
서버가 Kit에 의존하지 않는다면 독립 패키지를 사용하세요.
Sign In With Solana
Sign In With Solana(SIWS)는
챌린지 자체를 표준화합니다. 앱은 domain, statement, nonce, issuedAt 같은 구조화된
필드를 지갑의 solana:signIn 기능에 전달합니다. 지갑은 이를 표준 메시지로 구성하고,
필요하면 연결한 뒤 한 번에 서명합니다.
solana:signIn 버전 1.1.0에는 useOffchainMessage 옵션이 추가되었습니다.
useOffchainMessage: { messageVersion: 1 }을 전달하면, 지갑은 서명 계정만을 필수 서명자로 하는
버전 1 오프체인 메시지로 SIWS 텍스트를 감쌉니다. 하드웨어 지갑도 이를 표시하고 서명할 수 있으며,
출력에는 signedMessageFormat: { kind: "offchainMessage", messageVersion: 1 }이
보고됩니다. 버전 1.0.0을 알리는 지갑은 이 옵션을 인식하지 못하므로, 먼저 기능 버전을
확인하고 해당하지 않으면 옵션을 생략하세요.
solana:signIn은 계정 기능이 아닌 지갑 기능이므로, 이 헬퍼들은 UiWallet을 받습니다.
import {SolanaSignIn,type SolanaSignInFeature,type SolanaSignInInput} from "@solana/wallet-standard-features";import type { UiWallet } from "@wallet-standard/ui";import { getWalletFeature } from "@wallet-standard/ui-features";type SignInFeature = SolanaSignInFeature[typeof SolanaSignIn];export function supportsOffchainSignIn(wallet: UiWallet) {if (!wallet.features.includes(SolanaSignIn)) return false;const feature = getWalletFeature(wallet, SolanaSignIn) as SignInFeature;return feature.version === "1.1.0";}export async function signIn(wallet: UiWallet, input: SolanaSignInInput) {const feature = getWalletFeature(wallet, SolanaSignIn) as SignInFeature;const [output] = await feature.signIn({...input,...(supportsOffchainSignIn(wallet) && {useOffchainMessage: { messageVersion: 1 }})});return output;}
서버에서 일회용 nonce와 issuedAt 시각을 포함해 입력을 구성하고, 논스를 키로 하여 사본을
보관하세요. 로그인 검증이 완료되면 논스를 소진 처리하세요.
Wallet Adapter를 사용하지 않는 경우, @solana/wallet-standard-util의 verifySignIn은
일반 SIWS 출력만 검사합니다. signedMessageFormat이 오프체인 메시지로 보고되면,
signedMessage는 SIWS 텍스트를 내용으로 하는 오프체인 메시지입니다.
@solana/wallet-standard-util의 createSignInMessageText로 저장해 둔 입력과 서명 주소에서
예상 텍스트를 다시 구성한 다음, 서버에서 서명 검증하기에서처럼 다른 오프체인
메시지와 동일하게 검증하세요.
@solana/offchain-messages README에서
Kit을 사용한 이 흐름을 다룹니다.
지갑 개발자를 위한 안내
지갑은 @solana/wallet-standard-features의 solana:signOffchainMessage Wallet Standard
기능을 구현하여 지원을 추가합니다.
version: "1.0.0"을 보고하고, 서명할 수 있는 메시지 버전을supportedMessageVersions에 나열하세요. 현재는[1]입니다.- 정렬 및 중복 검사를 거친
requiredSigners로 버전 1 프리앰블을 구성하세요. 계정이 필수 서명자 중 하나가 아니면 요청을 거부하세요. - 서명하기 전에 하드웨어 기기를 포함한 모든 환경에서 메시지 텍스트 전체를 사용자에게 표시하세요.
- 서명한 바이트를 그대로
signedOffchainMessage로 반환하고, Ed25519signature도 함께 반환하세요. - 오프체인 Sign In With Solana를 지원하려면
solana:signIn에 대해version: "1.1.0"을 보고하세요. 입력에useOffchainMessage가 포함된 경우 SIWS 텍스트를 버전 1 오프체인 메시지로 서명하고, 해당 바이트를signedMessage로 반환한 다음signedMessageFormat을 설정하세요. solana:signMessage도 계속 지원하세요. 이 기능은 변경되지 않았으며, 앱에서 여전히 폴백(fallback)으로 사용합니다.
Is this page helpful?