Skip to content

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 내부에서 직접 요청

ts
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. 먼저 받아서 직접 전달

ts
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를 운영한다면 역할은 단순합니다.

  1. 브라우저에서 POST /api/ephemeral-key 같은 요청을 받습니다.
  2. 서버 설정에서 app_idsecret_key를 읽습니다.
  3. 서버 측에서 SORI Console API의 ephemeral endpoint를 호출합니다.
  4. 그 짧은 수명의 응답 payload를 브라우저에 그대로 반환합니다.

기본 SORI API base 기준 upstream endpoint는 다음과 같습니다.

txt
https://console.soriapi.com/api/auth/ephemeral

Upstream 요청 본문은 form-encoded 형식입니다.

txt
app_id=YOUR_APP_ID
secret_key=YOUR_SECRET_KEY

자체 endpoint는 장기 secret_key를 절대 브라우저에 반환하면 안 됩니다.

SORI Console API 응답 형식

sori_consolePOST /api/auth/ephemeral은 성공 시 다음 형태를 반환합니다.

json
{
  "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_idsecret_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
js
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");
});
python
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")
java
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
<?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나 인증 로직이 추가되더라도, 브라우저 계약은 단순하고 안정적으로 유지하는 것이 좋습니다.