Ephemeral Key
브라우저 코드에 장기 secret_key를 절대 노출하지 마세요.
Web 연동에서는 브라우저가 짧은 수명의 ephemeral key만 받아야 합니다. 실제로는 많은 파트너가 POST /api/ephemeral-key 같은 작은 서버 endpoint를 두고, 그 endpoint가 서버 측에서 SORI Console API를 호출하는 형태로 구현합니다.
왜 Ephemeral Key를 사용해야 하나요?
Web 연동에서는 브라우저 코드가 사용자에게 노출됩니다. 이 코드 안에 장기 secret_key를 넣으면 서비스 외부에서 추출되어 오용될 수 있습니다.
ephemeral key는 이 문제를 피하기 위한 장치입니다. 서버는 장기 secret_key를 안전하게 보관한 상태에서 수명이 짧은 ephemeral key를 발급받고, 그 임시 키만 브라우저에 전달합니다. 이후 브라우저는 원래 secret을 노출하지 않고도 인증과 이어지는 SDK API 호출에 그 ephemeral key를 사용할 수 있습니다.
권장 클라이언트 사용 방식
AudioRecognizer에는 보통 ephemeralKey를 통해 키를 전달하는 것이 좋습니다.
1. ephemeralKey 내부에서 직접 요청
import { AudioRecognizer } from "@sorisdk/web-audio";
const recognizer = new AudioRecognizer({
appId: "YOUR_APP_ID",
ephemeralKey: async () => {
const response = await fetch("/api/ephemeral-key", {
method: "POST"
});
if (!response.ok) {
throw new Error(`Ephemeral key request failed: HTTP ${response.status}`);
}
const { ephemeral_key } = await response.json();
return ephemeral_key;
}
});
recognizer.on("campaign", (event) => {
console.log(event.campaign);
});
await recognizer.start();2. 먼저 받아서 직접 전달
import { AudioRecognizer } from "@sorisdk/web-audio";
const response = await fetch("/api/ephemeral-key", {
method: "POST"
});
if (!response.ok) {
throw new Error(`Ephemeral key request failed: HTTP ${response.status}`);
}
const { ephemeral_key } = await response.json();
const recognizer = new AudioRecognizer({
appId: "YOUR_APP_ID",
ephemeralKey: ephemeral_key
});
recognizer.on("campaign", (event) => {
console.log(event.campaign);
});
await recognizer.start();자체 Key Exchange Endpoint 구현
자체 endpoint를 운영한다면 역할은 단순합니다.
- 브라우저에서
POST /api/ephemeral-key같은 요청을 받습니다. - 서버 설정에서
app_id와secret_key를 읽습니다. - 서버 측에서 SORI Console API의 ephemeral endpoint를 호출합니다.
- 그 짧은 수명의 응답 payload를 브라우저에 그대로 반환합니다.
기본 SORI API base 기준 upstream endpoint는 다음과 같습니다.
https://console.soriapi.com/api/auth/ephemeralUpstream 요청 본문은 form-encoded 형식입니다.
app_id=YOUR_APP_ID
secret_key=YOUR_SECRET_KEY자체 endpoint는 장기 secret_key를 절대 브라우저에 반환하면 안 됩니다.
SORI Console API 응답 형식
sori_console의 POST /api/auth/ephemeral은 성공 시 다음 형태를 반환합니다.
{
"success": true,
"ephemeral_key": "eyJhbGciOi...",
"ephemeral_key_expires_at": "2026-03-23T12:34:56Z"
}success: 요청 성공 여부ephemeral_key: 브라우저 SDK에 전달할 짧은 수명의 키ephemeral_key_expires_at: ISO 8601 형식의 만료 시각
서버 측 책임
app_id와secret_key는 환경 변수나 secret storage에 보관합니다.- SORI Console API는 form-encoded 데이터로 호출합니다.
- upstream 응답이 성공이 아니면 해당 상태 코드를 그대로 반환하고 종료합니다.
- 브라우저에는 짧은 수명의 응답 payload만 반환합니다.
구현 예제
아래 예제들은 모두 같은 key exchange 흐름을 구현합니다.
- Node.js:
POST /api/ephemeral-key - Python:
POST /api/ephemeral-key - Java:
POST /api/ephemeral-key - PHP:
POST /api/sori_ephemeral_key.php - upstream call:
POST https://console.soriapi.com/api/auth/ephemeral - browser response: SORI Console API와 같은 JSON shape
import express from "express";
const app = express();
const APP_ID = process.env.SORI_APP_ID;
const SECRET_KEY = process.env.SORI_SECRET_KEY;
const SORI_EPHEMERAL_ENDPOINT = "https://console.soriapi.com/api/auth/ephemeral";
app.post("/api/ephemeral-key", async (_req, res) => {
if (!APP_ID || !SECRET_KEY) {
res.status(500).json({ detail: "Missing SORI_APP_ID or SORI_SECRET_KEY" });
return;
}
try {
const upstream = await fetch(SORI_EPHEMERAL_ENDPOINT, {
method: "POST",
body: new URLSearchParams({
app_id: APP_ID,
secret_key: SECRET_KEY
})
});
const raw = await upstream.text();
if (!upstream.ok) {
res.status(upstream.status).type("application/json").send(raw);
return;
}
res.type("application/json").send(raw);
} catch (error) {
res.status(500).json({
detail: error instanceof Error ? error.message : String(error)
});
}
});
app.listen(3000, () => {
console.log("Listening on http://localhost:3000");
});import os
import requests
from fastapi import FastAPI
from fastapi.responses import JSONResponse, Response
app = FastAPI()
APP_ID = os.getenv("SORI_APP_ID")
SECRET_KEY = os.getenv("SORI_SECRET_KEY")
SORI_EPHEMERAL_ENDPOINT = "https://console.soriapi.com/api/auth/ephemeral"
@app.post("/api/ephemeral-key")
def create_sori_ephemeral_key():
if not APP_ID or not SECRET_KEY:
return JSONResponse(
status_code=500,
content={"detail": "Missing SORI_APP_ID or SORI_SECRET_KEY"},
)
try:
upstream = requests.post(
SORI_EPHEMERAL_ENDPOINT,
data={
"app_id": APP_ID,
"secret_key": SECRET_KEY,
},
timeout=10,
)
except requests.RequestException as exc:
return JSONResponse(status_code=500, content={"detail": str(exc)})
if not upstream.ok:
return Response(
content=upstream.text,
status_code=upstream.status_code,
media_type="application/json",
)
return Response(content=upstream.text, media_type="application/json")package example.sori;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.FormBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class SoriEphemeralKeyController {
private final OkHttpClient httpClient = new OkHttpClient();
private final ObjectMapper objectMapper = new ObjectMapper();
private final String appId = System.getenv("SORI_APP_ID");
private final String secretKey = System.getenv("SORI_SECRET_KEY");
private static final String SORI_EPHEMERAL_ENDPOINT =
"https://console.soriapi.com/api/auth/ephemeral";
@PostMapping(value = "/api/ephemeral-key", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> createEphemeralKey() {
if (isBlank(appId) || isBlank(secretKey)) {
return json(500, Map.of(
"detail", "Missing SORI_APP_ID or SORI_SECRET_KEY"
));
}
RequestBody body = new FormBody.Builder()
.add("app_id", appId)
.add("secret_key", secretKey)
.build();
Request request = new Request.Builder()
.url(SORI_EPHEMERAL_ENDPOINT)
.post(body)
.build();
try (Response response = httpClient.newCall(request).execute()) {
String rawBody = response.body() != null ? response.body().string() : "{}";
if (!response.isSuccessful()) {
return ResponseEntity.status(response.code())
.contentType(MediaType.APPLICATION_JSON)
.body(rawBody);
}
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(rawBody);
} catch (Exception error) {
return json(500, Map.of("detail", error.getMessage()));
}
}
private ResponseEntity<String> json(int status, Map<String, String> body) {
try {
return ResponseEntity.status(status)
.contentType(MediaType.APPLICATION_JSON)
.body(objectMapper.writeValueAsString(body));
} catch (Exception error) {
return ResponseEntity.status(status)
.contentType(MediaType.APPLICATION_JSON)
.body("{\"detail\":\"Unexpected server error\"}");
}
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
}<?php
// /api/sori_ephemeral_key.php
header('Content-Type: application/json');
$appId = getenv('SORI_APP_ID');
$secretKey = getenv('SORI_SECRET_KEY');
$soriEphemeralEndpoint = 'https://console.soriapi.com/api/auth/ephemeral';
if (!$appId || !$secretKey) {
http_response_code(500);
echo json_encode(['detail' => 'Missing SORI_APP_ID or SORI_SECRET_KEY']);
exit;
}
$curl = curl_init($soriEphemeralEndpoint);
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'app_id' => $appId,
'secret_key' => $secretKey,
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
],
]);
$raw = curl_exec($curl);
$statusCode = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
if ($raw === false) {
http_response_code(500);
echo json_encode(['detail' => curl_error($curl)]);
curl_close($curl);
exit;
}
curl_close($curl);
if ($statusCode < 200 || $statusCode >= 300) {
http_response_code($statusCode);
echo $raw;
exit;
}
echo $raw;참고 사항
- 브라우저는 장기 secret 관리 경로가 아니라, 짧은 수명의 키를 반환하는 endpoint만 호출해야 합니다.
- 여러 SORI 앱을 운영한다면 요청, tenant, 배포 환경에 따라 다른
app_id를 선택하도록 relay를 확장할 수 있습니다. - 프레임워크별 middleware나 인증 로직이 추가되더라도, 브라우저 계약은 단순하고 안정적으로 유지하는 것이 좋습니다.
