재생 토큰(playback_token) 연동 가이드
브라우저 플레이어가 쓸 playback_token 과 DASH manifest 주소를
서버에서 발급받아 내려주는 방법입니다. 언어별 서버측 예제를 제공합니다.
대상 · 고객사 백엔드 개발자
흐름
브라우저는 DRM 서버를 직접 부르지 않습니다. 고객사 백엔드가 중간에서 API 키로 토큰을 받아 브라우저에 넘깁니다.
POST /api/token { content_id } — 자기 백엔드에만 요청
POST /api/spk/playback-token (X-SPK-API-Key) — 서버 간 호출. 키는 서버에만
playback_token + manifest(dash/hls) 주소 반환
POST /api/playback/session (Authorization: Bearer playback_token) — 재생 세션 생성 → session_token (동시접속·기기 제한)
playback_token + session_token 으로 Widevine(사파리는 FairPlay) 라이선스 요청 → 복호화·재생
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>
최신 브라우저용 fetch + StarPlyr 전체 옵션 예시는 DRM 재생 샘플 참고.
자주 겪는 문제
| 증상 | 원인 | 해결 |
|---|---|---|
| 브라우저에서 API 키가 보인다 | 프론트에서 DRM 서버 직접 호출 | 백엔드 프록시를 둔다 |
| 401 / invalid api key | X-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 를 넣어 워터마크·재생 이력이 남게 했다 |