재생 토큰(playback_token) 연동 가이드

브라우저 플레이어가 쓸 playback_token 과 DASH manifest 주소를 서버에서 발급받아 내려주는 방법입니다. 언어별 서버측 예제를 제공합니다.

대상 · 고객사 백엔드 개발자

흐름

브라우저는 DRM 서버를 직접 부르지 않습니다. 고객사 백엔드가 중간에서 API 키로 토큰을 받아 브라우저에 넘깁니다.

1
브라우저 → 고객사 백엔드

POST /api/token { content_id } — 자기 백엔드에만 요청

2
백엔드 → DRM 서버

POST /api/spk/playback-token (X-SPK-API-Key) — 서버 간 호출. 키는 서버에만

3
백엔드 → 브라우저

playback_token + manifest(dash/hls) 주소 반환

4
브라우저 → DRM 서버

POST /api/playback/session (Authorization: Bearer playback_token) — 재생 세션 생성 → session_token (동시접속·기기 제한)

5
StarPlyr 재생

playback_token + session_token 으로 Widevine(사파리는 FairPlay) 라이선스 요청 → 복호화·재생

반드시. API 키(X-SPK-API-Key)는 절대 브라우저로 내려보내지 않습니다. 백엔드에서만 호출하고 결과만 줍니다. 토큰은 만료가 짧으니(기본 1시간) 재생 시작마다 새로 발급합니다.

DRM 엔드포인트 규격

# 요청
POST https://drm.starplayer.net/api/spk/playback-token
Content-Type: application/json
X-SPK-API-Key: ovp_xxxxxxxx          ← 서버에서만 붙인다

{
  "content_id": "9e45f7fb-ebfa-42d0-9c0f-b22b995cce53",
  "user_id": "student001",
  "expires_in": 3600
}

요청 body

필드필수설명
content_id필수DRM 콘텐츠 ID(UUID). 게시(published) 상태여야 한다
user_id권장사용자 식별자. 워터마크·재생 이력에 쓰이고, 토큰의 uid 로 박힌다(정본)
expires_in선택토큰 유효기간(초). 기본 3600, 서버 상한보다 크면 상한으로 깎인다

응답 (200 OK)

{
  "playback_token": "eyJhbGciOiJIUzI1NiIs...",          // 브라우저 플레이어에 넘긴다
  "expires_in": 3600,
  "manifest": {
    "dash": "https://.../dash/manifest.mpd",             // Widevine·일반 브라우저
    "hls":  "https://.../dash/master.m3u8"                // FairPlay(사파리)
  }
}

서버측 — 토큰 발급 프록시

브라우저의 POST /api/token 을 받아 DRM 서버를 호출하고, 응답을 그대로 브라우저에 돌려줍니다. API 키는 이 계층에만 둡니다.

// SPK_API_KEY 는 환경변수 등 서버에만 둔다 — 소스·응답에 노출 금지
const express = require('express');
const app = express();
app.use(express.json());

const DRM_SERVER  = 'https://drm.starplayer.net';
const SPK_API_KEY = process.env.SPK_API_KEY;

app.post('/api/token', async (req, res) => {
  const { content_id, user_id } = req.body;
  if (!content_id) return res.status(400).json({ error: 'missing_content_id' });

  const r = await fetch(`${DRM_SERVER}/api/spk/playback-token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-SPK-API-Key': SPK_API_KEY },
    body: JSON.stringify({ content_id, user_id: user_id || 'demo', expires_in: 3600 }),
  });
  const data = await r.json();      // { playback_token, expires_in, manifest }
  return res.status(r.status).json(data);
});

app.listen(8080);
// token.php — 브라우저가 POST /token.php { content_id, user_id } 로 호출
<?php
header('Content-Type: application/json; charset=utf-8');

const DRM_SERVER = 'https://drm.starplayer.net';
$SPK_API_KEY = getenv('SPK_API_KEY');          // 서버에만 둔다

$in = json_decode(file_get_contents('php://input'), true) ?: $_POST;
if (empty($in['content_id'])) { http_response_code(400); echo '{"error":"missing_content_id"}'; exit; }

$ch = curl_init(DRM_SERVER . '/api/spk/playback-token');
curl_setopt_array($ch, [
  CURLOPT_POST => true,
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_TIMEOUT => 15,
  CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-SPK-API-Key: ' . $SPK_API_KEY],
  CURLOPT_POSTFIELDS => json_encode([
    'content_id' => $in['content_id'],
    'user_id'    => $in['user_id'] ?? 'demo',
    'expires_in' => 3600,
  ]),
]);
$raw  = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

http_response_code($code ?: 502);
echo $raw !== false ? $raw : '{"error":"upstream_failed"}';   // 응답 그대로 전달
' token.asp — ServerXMLHTTP 로 DRM 서버 호출. API 키는 서버에만.
<%
Response.ContentType = "application/json"

Dim DRM_SERVER, SPK_API_KEY
DRM_SERVER  = "https://drm.starplayer.net"
SPK_API_KEY = "ovp_xxxxxxxx"          ' 실제로는 설정 파일 등 소스 밖에 두기를 권장

Dim contentId, userId
contentId = Request.Form("content_id")
userId    = Request.Form("user_id")
If contentId = "" Then
  Response.Status = "400 Bad Request"
  Response.Write "{""error"":""missing_content_id""}"
  Response.End
End If
If userId = "" Then userId = "demo"

Dim http, payload
payload = "{""content_id"":""" & contentId & """,""user_id"":""" & userId & """,""expires_in"":3600}"

Set http = Server.CreateObject("MSXML2.ServerXMLHTTP.6.0")
http.Open "POST", DRM_SERVER & "/api/spk/playback-token", False
http.setRequestHeader "Content-Type", "application/json"
http.setRequestHeader "X-SPK-API-Key", SPK_API_KEY
http.send payload

Response.Status = http.status & " OK"
Response.Write http.responseText     ' 응답 그대로 전달
Set http = Nothing
%>
// TokenController.cs (ASP.NET Core) — POST /api/token
[ApiController]
public class TokenController : ControllerBase
{
    private const string DrmServer = "https://drm.starplayer.net";
    private static readonly HttpClient Http = new HttpClient();

    [HttpPost("/api/token")]
    public async Task<IActionResult> Issue([FromBody] TokenReq req)
    {
        if (string.IsNullOrEmpty(req.content_id))
            return BadRequest(new { error = "missing_content_id" });

        var body = new {
            content_id = req.content_id,
            user_id    = string.IsNullOrEmpty(req.user_id) ? "demo" : req.user_id,
            expires_in = 3600,
        };
        var msg = new HttpRequestMessage(HttpMethod.Post, $"{DrmServer}/api/spk/playback-token")
        {
            Content = JsonContent.Create(body),
        };
        msg.Headers.Add("X-SPK-API-Key", Environment.GetEnvironmentVariable("SPK_API_KEY"));

        var r = await Http.SendAsync(msg);
        var json = await r.Content.ReadAsStringAsync();
        return Content(json, "application/json");   // 응답 그대로 전달
    }

    public class TokenReq { public string content_id { get; set; } public string user_id { get; set; } }
}
# SPK_API_KEY 는 환경변수 등 서버에만 둔다
import os, requests
from flask import Flask, request, jsonify

app = Flask(__name__)
DRM_SERVER  = "https://drm.starplayer.net"
SPK_API_KEY = os.environ["SPK_API_KEY"]

@app.post("/api/token")
def issue_token():
    body = request.get_json(silent=True) or {}
    content_id = body.get("content_id")
    if not content_id:
        return jsonify(error="missing_content_id"), 400

    r = requests.post(
        f"{DRM_SERVER}/api/spk/playback-token",
        headers={"Content-Type": "application/json", "X-SPK-API-Key": SPK_API_KEY},
        json={"content_id": content_id, "user_id": body.get("user_id", "demo"), "expires_in": 3600},
        timeout=15,
    )
    return jsonify(r.json()), r.status_code   # 응답 그대로 전달
// TokenController.java — POST /api/token
@RestController
public class TokenController {
    private static final String DRM_SERVER = "https://drm.starplayer.net";
    private final HttpClient http = HttpClient.newHttpClient();

    @PostMapping("/api/token")
    public ResponseEntity<String> issue(@RequestBody Map<String, String> req) throws Exception {
        String contentId = req.get("content_id");
        if (contentId == null || contentId.isEmpty())
            return ResponseEntity.badRequest().body("{\"error\":\"missing_content_id\"}");

        String userId = req.getOrDefault("user_id", "demo");
        // 실제로는 Jackson 등 JSON 라이브러리로 직렬화할 것 (값에 " 가 들어가면 깨진다)
        String payload = String.format(
            "{\"content_id\":\"%s\",\"user_id\":\"%s\",\"expires_in\":3600}", contentId, userId);

        HttpRequest r = HttpRequest.newBuilder()
            .uri(URI.create(DRM_SERVER + "/api/spk/playback-token"))
            .header("Content-Type", "application/json")
            .header("X-SPK-API-Key", System.getenv("SPK_API_KEY"))
            .POST(HttpRequest.BodyPublishers.ofString(payload))
            .build();

        HttpResponse<String> resp = http.send(r, HttpResponse.BodyHandlers.ofString());
        return ResponseEntity.status(resp.statusCode())
            .header("Content-Type", "application/json")
            .body(resp.body());   // 응답 그대로 전달
    }
}

재생 세션 발급 — 동시접속·기기 제한 (X-Session-Token)

재생 토큰을 받은 뒤, 브라우저가 DRM 서버에 재생 세션을 만들어 session_token 을 받습니다. 이 값이 라이선스 요청에 X-Session-Token 으로 실려 동시접속 수·기기 수 정책과 이상탐지를 적용합니다. 세션 생성은 재생 토큰(Bearer) 으로 인증하므로 API 키가 필요 없어 브라우저에서 직접 부릅니다.

# 요청
POST https://drm.starplayer.net/api/playback/session
Authorization: Bearer {playback_token}       ← 앞서 받은 재생 토큰
Content-Type: application/json

{ "content_id": "...", "device_id": "브라우저별 고정 UUID", "user_id": "student001" }

# 응답 (200)
{
  "session_token": "47c744959...",   // StarPlyr sessionToken 으로 전달 → X-Session-Token
  "expires_in": 300,                 // 수명이 짧다 — 재생 시작 직전에 만든다
  "manifest": { "dash": "...", "hls": "..." }
}
주의. session_token 은 수명이 짧습니다(예: 300초). 재생을 시작하기 직전에 발급하고, device_id 는 브라우저별 고정값(localStorage UUID)을 씁니다. 재생 유지·갱신은 플레이어가 하트비트로 처리합니다.

브라우저측 — 레거시 JavaScript (ES5 / XHR)

fetch 를 못 쓰는 구형 브라우저용. 백엔드에서 재생 토큰 → DRM 서버에서 재생 세션 → 재생 순서입니다. API 키는 등장하지 않습니다.

<script src="/player/lib/starplyr/starplyr.min.js"></script>
<video id="video" crossorigin playsinline></video>
<script>
// 작은 XHR 헬퍼 — POST JSON → JSON. auth 가 있으면 Authorization 헤더를 붙인다.
function postJson(url, body, done, auth) {
  var xhr = new XMLHttpRequest();
  xhr.open('POST', url, true);
  xhr.setRequestHeader('Content-Type', 'application/json');
  if (auth) xhr.setRequestHeader('Authorization', auth);
  xhr.onreadystatechange = function () {
    if (xhr.readyState !== 4) return;
    if (xhr.status !== 200) { alert('요청 실패: ' + url); return; }
    done(JSON.parse(xhr.responseText));
  };
  xhr.send(JSON.stringify(body));
}

function getDeviceId() {                       // 브라우저별 고정 UUID
  var id = localStorage.getItem('sp_device');
  if (!id) { id = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : String(Date.now()) + Math.random();
             localStorage.setItem('sp_device', id); }
  return id;
}

function playContent(contentId) {
  // 1) 백엔드에서 재생 토큰 발급 (API 키는 백엔드에만)
  postJson('/api/token', { content_id: contentId, user_id: 'student001' }, function (tok) {
    var deviceId = getDeviceId();
    // 2) 재생 토큰(Bearer)으로 DRM 서버에 재생 세션 생성 → session_token
    postJson('https://drm.starplayer.net/api/playback/session',
      { content_id: contentId, device_id: deviceId, user_id: 'student001' },
      function (sess) { startPlayer(contentId, tok, sess.session_token, deviceId); },
      'Bearer ' + tok.playback_token);
  });
}

function startPlayer(contentId, tok, sessionToken, deviceId) {
  var isFairPlay = !!window.WebKitMediaKeys;   // 사파리
  var dashUrl = tok.manifest && tok.manifest.dash;
  var hlsUrl  = tok.manifest && tok.manifest.hls;
  var manifestUrl = isFairPlay ? (hlsUrl || dashUrl) : dashUrl;

  var player = new StarPlyr('#video', {
    autoplay: true,
    drm: {
      enabled: true,
      drmType: isFairPlay ? 'fairplay' : 'widevine',
      baseUrl: 'https://drm.starplayer.net',
      getToken: tok.playback_token,   // → Authorization: Bearer (재생 토큰)
      sessionToken: sessionToken,     // → X-Session-Token (재생 세션)
      deviceId: deviceId,             // → X-Device-ID
      contentId: contentId,
      manifest: { dash: dashUrl, hls: hlsUrl }
    }
  });
  player.source = {
    type: 'video',
    sources: [{ src: manifestUrl, type: 'video/mp4', provider: 'starplayer', contentid: contentId }]
  };
}
</script>

자주 겪는 문제

증상원인해결
브라우저에서 API 키가 보인다프론트에서 DRM 서버 직접 호출백엔드 프록시를 둔다
401 / invalid api keyX-SPK-API-Key 누락·오타헤더 이름·키 값 확인
manifest 가 응답에 없다콘텐츠 미게시 / 잘못된 content_id게시 상태·ID 확인
재생 중 라이선스 실패토큰 만료(기본 1시간)재생 시작마다 토큰 재발급
사파리에서 재생 안 됨dash 를 넘김사파리는 manifest.hls(FairPlay)
CORS 차단다른 출처의 백엔드 호출응답에 CORS 헤더 설정 또는 같은 출처 배치

적용 전 체크리스트

☐ SPK API Key 가 서버에만 있고 페이지 소스·응답에 노출되지 않는다
☐ 브라우저는 자기 백엔드(/api/token)만 부르고 DRM 서버를 직접 부르지 않는다
☐ 재생 토큰을 재생 시작 시점마다 새로 발급한다 (캐시·재사용 금지)
manifest.dash/hls 를 절대 URL 그대로 넘긴다
☐ 사파리(FairPlay)는 hls, 그 외는 dash 로 분기했다
user_id 를 넣어 워터마크·재생 이력이 남게 했다