Azure Speech の音声合成や音声アバターを SPA(Angular)から使いたいが、ブラウザにサブスクリプションキーは絶対に置きたくない…そんなときに使えるのが「短期トークン配布 API」パターンです。本記事では、推奨アーキテクチャと Angular 実装、マネージド ID の正しい使い方まで一気に整理します。
Azure Speech を SPA(Angular)から安全に使うべき理由
Azure Speech(Speech Service)のデモコードでは、ブラウザ側でサブスクリプションキー(リソースキー)を直接使って SDK を初期化する例がよく出てきます。しかし本番システムで同じことをすると、次のようなリスクを抱えることになります。
- ブラウザの開発者ツールから サブスクリプションキーが丸見え(コンソールやソースマップ、Network タブなど)。
- 漏えいしたキーを第三者が 別の環境から勝手に叩ける(レート超過や高額請求の原因)。
- キーのローテーションが難しくなり、長期的に 運用負債 になる。
一方、Azure Speech では「サブスクリプションキーを /sts/v1.0/issueToken に渡すと、約 10 分間有効なアクセストークン(JWT)が取得できる」仕組みが提供されています。 この仕組みをうまく使うことで、ブラウザ側には「短命なトークンだけ」を渡し、長期の秘密情報はすべてバックエンドと Key Vault に閉じ込める設計にできます。
本記事のゴールは、次の 3 点です。
- Angular SPA から Azure Speech を安全に呼び出すアーキテクチャを理解する。
- バックエンドに「トークン配布 API」を実装し、Key Vault とマネージド ID を正しく使う。
- 音声・アバター機能を支えるトークン更新ロジックやセキュリティ運用のポイントを押さえる。
推奨アーキテクチャの全体像
おすすめの構成はシンプルにまとめると次の通りです。
- バックエンド(トークンプロバイダ):サブスクリプションキーを使って
issueTokenを叩き、短期トークンを発行するだけの API。 - Angular SPA(フロント):Speech SDK を
fromAuthorizationTokenで初期化し、必要に応じてトークンを更新しながら音声・アバター機能を呼び出す。 - Key Vault + マネージド ID:バックエンドがサブスクリプションキーなどの秘密情報を取得するためだけに利用。クライアントには一切渡さない。
ユーザー (ブラウザ:Angular SPA)
│ 1. 認証(Microsoft Entra ID 等)
▼
フロントエンド (Angular)
│ 2. /api/speech-token を呼び出し
▼
バックエンド (Token Provider API)
│ 3. マネージド ID で Key Vault へ
│ 4. サブスクリプションキー取得
│ 5. https://<region>.api.cognitive.microsoft.com/sts/v1.0/issueToken へ POST
▼
Azure Speech STS
│ 6. 約10分有効のトークン発行
▼
バックエンド → フロント
│ 7. 短期トークン + region を返却
▼
フロント
│ 8. SpeechConfig.fromAuthorizationToken(token, region)
▼
Azure Speech 本体(音声・アバター)
このときの責務分担を表にすると、設計意図がよりクリアになります。
| レイヤー | 主な責務 | 保持する秘密情報 |
|---|---|---|
| Angular SPA | UI、音声操作、アバター制御、トークンの短期キャッシュ | なし(最大でも「短期トークン」をメモリ保持) |
| トークン配布 API | 認証済ユーザーからのトークン要求を受け、Speech トークンを発行して返す | Key Vault から取り出したサブスクリプションキー(メモリ上) |
| Key Vault | サブスクリプションキーなど長期秘密の安全な保管 | Speech リソースキー、他のシークレット |
| マネージド ID | バックエンドから Key Vault へ安全にアクセスするための ID | 秘密情報は持たず、Azure 側が管理する |
やってはいけない 2 つのパターン
ブラウザにサブスクリプションキーを直接置く
もっとも危険なのが「デモコードのまま、サブスクリプションキーをフロントに埋め込む」パターンです。
- JavaScript のバンドルをダウンロードすれば、キーは誰でも取得できる。
- たとえ「環境変数」や「CI 秘密設定」を使っても、最終的にブラウザに配布されるなら意味がない。
- CDN キャッシュやログに残る可能性もあり、漏えい後の影響範囲が読めない。
これは 本番環境では絶対に避けるべきアンチパターン です。
マネージド ID や Azure AD アクセストークンをそのままクライアントに渡す
「マネージド ID で取得した Azure AD アクセストークンをブラウザに渡して、Speech SDK に食べさせればよいのでは?」というアイデアも危険です。
- Azure AD のアクセストークンは 60〜90 分程度の有効期限 が一般的で、短期トークンより攻撃者にとって扱いやすい。
- 権限のスコープ次第では、Speech 以外の Azure リソースにもアクセスできてしまう。
- ブラウザに配布した瞬間、XSS 等で盗まれれば大きな被害につながる。
Azure 公式のガイダンスでも、クライアントに長期・高権限のトークンを渡すのではなく、バックエンドで必要最小限の短期トークンに交換することが推奨されています。
したがって、マネージド ID は 「バックエンド → Key Vault や Speech リソースへのアクセス」にだけ使う、というスタンスを徹底しましょう。
バックエンド実装:トークン配布 API を作る
ここからは、Node.js(TypeScript)を例に「トークン配布 API」を実装するパターンを見ていきます(.NET でも考え方は同じです)。
前提となる Azure リソース
- Azure Speech リソース(キーとリージョンを保持)
- Azure Key Vault(Speech のサブスクリプションキーを格納)
- バックエンド(App Service / Azure Functions / コンテナなど)
- バックエンドに割り当てたシステム割り当てマネージド ID
Key Vault への権限設定
- Key Vault に
speech-subscription-keyのような名前でシークレットを作成。 - アクセス ポリシーまたは RBAC で、バックエンドのマネージド ID に「Secret Get」権限を付与。
Node.js(Express)でのトークン配布 API 実装例
簡略化したサンプルです。実際には認証(JWT 検証など)を必須にしてください。
import express from 'express';
import axios from 'axios';
import { SecretClient } from '@azure/keyvault-secrets';
import { DefaultAzureCredential } from '@azure/identity';
const app = express();
const port = process.env.PORT || 3000;
const keyVaultName = process.env.KEY_VAULT_NAME!;
const kvUrl = `https://${keyVaultName}.vault.azure.net`;
const speechKeySecretName = process.env.SPEECH_KEY_SECRET_NAME || 'speech-subscription-key';
const speechRegion = process.env.SPEECH_REGION!; // 例: japaneast, eastus など
const credential = new DefaultAzureCredential();
const secretClient = new SecretClient(kvUrl, credential);
async function getSpeechSubscriptionKey(): Promise<string> {
const secret = await secretClient.getSecret(speechKeySecretName);
if (!secret.value) {
throw new Error('Speech subscription key not found in Key Vault.');
}
return secret.value;
}
// 認証必須エンドポイントにすること!
app.post('/api/speech-token', async (req, res) => {
try {
// ここでユーザーの認証・認可チェックを行う(省略)
const subscriptionKey = await getSpeechSubscriptionKey();
const tokenEndpoint =
`https://${speechRegion}.api.cognitive.microsoft.com/sts/v1.0/issueToken`;
const response = await axios.post(
tokenEndpoint,
null,
{
headers: {
'Ocp-Apim-Subscription-Key': subscriptionKey,
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': '0'
}
}
);
const token = response.data as string;
// Speech トークンは約 10 分有効
const expiresInSeconds = 600;
res.json({
token,
region: speechRegion,
expiresIn: expiresInSeconds
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to issue speech token' });
}
});
app.listen(port, () => {
console.log(`Token provider listening on port ${port}`);
});
/sts/v1.0/issueToken への POST で、サブスクリプションキーを 10 分有効なトークンに交換できる点がポイントです。
レスポンス設計の例
クライアント側で扱いやすいよう、シンプルな JSON を返します。
{
"token": "<authorization token string>",
"region": "japaneast",
"expiresIn": 600
}
この expiresIn は「おおよその有効期限(秒)」をクライアントに伝えるための目安です。実際には JWT のクレームに有効期限が入っていますが、フロントで JWT を解析しない設計にしておくと責務がシンプルになります。
Angular フロントエンドからトークンを使う
次に、Angular からトークン配布 API を呼び出し、Speech SDK を初期化する実装を見ていきます。
Speech SDK のインストール
npm install microsoft-cognitiveservices-speech-sdk --save
TypeScript でも型定義が同梱されているので、そのまま利用できます。
トークン取得用サービスの実装(speech-token.service.ts)
トークンの「取得」と「キャッシュ」を 1 カ所に閉じ込めておくと、アプリ全体で再利用しやすくなります。
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { lastValueFrom } from 'rxjs';
interface SpeechTokenResponse {
token: string;
region: string;
expiresIn: number; // 秒
}
@Injectable({ providedIn: 'root' })
export class SpeechTokenService {
private token: string | null = null;
private region: string | null = null;
private expiresAt: number | null = null; // Unix ms
constructor(private http: HttpClient) {}
async getValidToken(): Promise<{ token: string; region: string }> {
const now = Date.now();
// 期限の 1 分前までは再利用
if (this.token && this.expiresAt && now < this.expiresAt - 60_000) {
return { token: this.token, region: this.region! };
}
const response = await lastValueFrom(
this.http.post<SpeechTokenResponse>('/api/speech-token', {})
);
this.token = response.token;
this.region = response.region;
this.expiresAt = now + response.expiresIn * 1000;
return { token: this.token, region: this.region };
}
// 必要なら手動でトークンをクリアするメソッドを用意しても良い
clear() {
this.token = null;
this.region = null;
this.expiresAt = null;
}
}
トークンは メモリにのみ保持し、localStorage や sessionStorage には保存しない 点が重要です。ブラウザ再読込時は再度 API を叩いて取得し直します。
Speech SDK をラップするサービスの実装例(azure-speech.service.ts)
import { Injectable } from '@angular/core';
import * as sdk from 'microsoft-cognitiveservices-speech-sdk';
import { SpeechTokenService } from './speech-token.service';
@Injectable({ providedIn: 'root' })
export class AzureSpeechService {
private speechConfig: sdk.SpeechConfig | null = null;
constructor(private tokenService: SpeechTokenService) {}
private async getSpeechConfig(): Promise<sdk.SpeechConfig> {
const { token, region } = await this.tokenService.getValidToken();
if (!this.speechConfig) {
// サブスクリプションキーではなく Authorization Token で初期化
this.speechConfig = sdk.SpeechConfig.fromAuthorizationToken(token, region);
} else {
// 既存 config のトークンだけを更新することも可能
this.speechConfig.authorizationToken = token;
}
return this.speechConfig;
}
async synthesizeText(text: string): Promise<void> {
const config = await this.getSpeechConfig();
const audioConfig = sdk.AudioConfig.fromDefaultSpeakerOutput();
const synthesizer = new sdk.SpeechSynthesizer(config, audioConfig);
return new Promise((resolve, reject) => {
synthesizer.speakTextAsync(
text,
result => {
synthesizer.close();
if (result.reason === sdk.ResultReason.SynthesizingAudioCompleted) {
resolve();
} else {
reject(result.errorDetails);
}
},
error => {
synthesizer.close();
reject(error);
}
);
});
}
async recognizeOnce(): Promise<string> {
const config = await this.getSpeechConfig();
config.speechRecognitionLanguage = 'ja-JP';
const audioConfig = sdk.AudioConfig.fromDefaultMicrophoneInput();
const recognizer = new sdk.SpeechRecognizer(config, audioConfig);
return new Promise((resolve, reject) => {
recognizer.recognizeOnceAsync(
result => {
recognizer.close();
if (result.reason === sdk.ResultReason.RecognizedSpeech) {
resolve(result.text);
} else {
reject(result.errorDetails);
}
},
error => {
recognizer.close();
reject(error);
}
);
});
}
}
ここでのポイントは次の通りです。
SpeechConfig.fromSubscriptionではなく、fromAuthorizationTokenを必ず使う。- トークン更新時は
speechConfig.authorizationToken = newTokenのように差し替えられる。 - 長時間のセッション(チャットアバターなど)では、一定時間ごとに
getSpeechConfig()内でトークンを更新するイメージで運用する。
コンポーネント側からの利用例
import { Component } from '@angular/core';
import { AzureSpeechService } from './azure-speech.service';
@Component({
selector: 'app-speech-demo',
template: `
<button (click)="speak()">読み上げ</button>
<button (click)="recognize()">音声認識</button>
<p>認識結果: {{ recognizedText }}</p>
`
})
export class SpeechDemoComponent {
recognizedText = '';
constructor(private speech: AzureSpeechService) {}
async speak() {
await this.speech.synthesizeText('こんにちは。Azure Speech と Angular です。');
}
async recognize() {
this.recognizedText = await this.speech.recognizeOnce();
}
}
音声アバター(Chat Avatar)でも同じパターンで使える
Azure Speech の音声アバター(Chat Avatar)機能を利用する場合も、基本的な認証パターンは同じです。
SpeechConfigの初期化方法が Authorization Token + Region である点はまったく同じ。- アバター用の SDK / WebRTC 接続部分で、
SpeechConfigまたはトークン文字列を渡すだけ。 - セッションが 10 分を超える場合は、裏でトークンを更新する実装が必要。
つまり、バックエンドのトークン配布 API と Angular 側のトークン管理サービスをしっかり作っておけば、テキスト読み上げ・音声認識・アバターのどれに対しても 認証パターンを使い回せる というメリットがあります。
トークンの性質と更新タイミングの設計
ここで、Speech トークンの性質と更新設計を一度整理しておきます。
| 項目 | 値の例 | 備考 |
|---|---|---|
| 発行元 | /sts/v1.0/issueToken | Speech リソースキーと引き換えに発行 |
| 形式 | JWT 文字列 | クライアント側でパースする必要はない |
| 有効期限 | 約 10 分 | 公式ドキュメントでも 10 分有効と案内 |
| 推奨再取得タイミング | 9 分経過または残り 10〜20% 時 | ネットワーク遅延や時計ずれのバッファを考慮 |
運用上のおすすめは次のようなイメージです。
0 分 1 2 3 4 5 6 7 8 9 10 分
|----------------------------|----------|
トークン発行・利用期間 再取得バッファ
・発行直後〜9分までは同じトークンを再利用
・8〜9分あたりでバックグラウンドで新しいトークンを取得
・新トークンを SpeechConfig に差し替え、セッション継続
長時間セッションを扱う場合は、次のような「自動更新フック」を設計しておくと安心です。
- 定期的に
getValidToken()を呼び出すタイマー(例:5 分ごと)。 - Speech SDK のエラーが「トークン期限切れ」のときに自動でトークン再取得してリトライ。
- バックエンド側で
expiresInを短め(例:540 秒)にして返すことで、余裕を持った更新を促す。
マネージド ID の正しい使い方とアンチパターン
マネージド ID の「正しい役割」
マネージド ID(Managed Identity)は、あくまで Azure 内のリソース同士を安全に認証するためのしくみ です。
- App Service や Azure Functions に「システム割り当て ID」を有効化する。
- Key Vault や Storage、Service Bus などに対して「この ID には Secret Get だけ許可」のように権限を付与。
- バックエンドコードからは
DefaultAzureCredentialを使うだけで、秘密キーなしでリソースにアクセスできる。
ここで重要なのは、マネージド ID 自体をクライアントに渡すことはできないし、渡す設計にしてもいけない という点です。マネージド ID は Azure 内の「裏口的な認証手段」であり、ブラウザやモバイルアプリに配るものではありません。
よくある誤解パターン
- 「マネージド ID で取得したトークンをブラウザに渡して、Speech SDK に使わせれば安全なのでは?」
→ そのトークンは通常、Speech 以外のリソースにもアクセスできる可能性があり、かつ有効期限も長いため、漏えいしたときの被害が大きくなります。 - 「クライアントから直接 Key Vault を呼べばいいのでは?」
→ Key Vault は基本的にサーバーサイドから使うことが前提であり、クライアントから直接叩くことは想定されていません。
結論として、マネージド ID は 「バックエンドの味方」 としてだけ使い、ブラウザ側は常に「短期トークン」だけを扱う設計を徹底しましょう。
セキュリティ・運用設計のチェックポイント
ここまでの内容を踏まえ、運用面で必ず押さえておきたいポイントを整理します。
認証と認可
/api/speech-tokenは必ず 認証必須 にする(匿名アクセス禁止)。- Microsoft Entra ID(Azure AD)や B2C、独自 JWT など、アプリ全体で使っている認証方式に合わせる。
- 必要なら、「音声機能利用可能なロール」を別途設け、ロールに応じて利用可否を制御する。
レート制限
- ユーザー ID または IP 単位でトークン発行回数を制限(例:1 分あたり 5 回まで)。
- Azure API Management や Application Gateway / WAF と組み合わせると設定しやすい。
- 異常なアクセスパターン(大量発行、深夜帯の集中など)を検知し、アラートにつなげる。
CORS 設定
/api/speech-tokenの CORS は 自社ドメインのみに限定 する。*(ワイルドカード)は極力使わない。- 開発環境と本番環境で許可オリジンを明確に分ける。
ログと監査
- ログに 絶対にトークン本体やサブスクリプションキーを出力しない。
- ログに残すべき情報の例:
- 発行日時
- 発行対象ユーザー ID
- クライアント IP(可能であれば)
- 利用したリージョン
- 分析基盤(Log Analytics / Application Insights 等)と連携し、不審なパターンの検知ルールを用意する。
キーのローテーション
- Speech リソースキーを 2 本持ち、順次ローテーションする運用を設計。
- Key Vault 側で新しいバージョンを追加し、古いバージョンを削除するフローを決める。
- ローテーション時は、バックエンドを優先的に新キーに切り替え、問題がなければ旧キーを失効させる。
よくある質問とその答え
Q. Speech トークンの有効期限はどのくらい?
A. 一般的に 約 10 分 です。Microsoft のドキュメントでも、issueToken で発行されるトークンは 10 分有効と説明されています。 連続利用する場合は、残り時間に余裕を持って再取得しましょう。
Q. デモである「API キー入力欄」はどう置き換えればいい?
A. 次のような変更が基本です。
- 画面上の「API キー入力欄」を廃止する。
- ユーザーがログインしていることを前提に、フロントから
/api/speech-tokenを呼び出す。 - 取得したトークンとリージョンで Speech SDK を初期化する。
認証はアプリ全体のログイン機能に任せ、ユーザーが何も意識せずに安全なトークンが使われる設計が理想です。
Q. 音声アバターでもこの方式は使える?
A. はい、基本的に同じ方式で利用できます。音声合成・音声認識・アバターのいずれも、Speech SDK 初期化時の認証手段が「サブスクリプションキー」か「短期トークン」かの違いだけです。アバター用 SDK がトークン文字列または SpeechConfig を受け取るのであれば、その前段として今回紹介したトークン配布 API を挟めば OK です。
Q. Azure AD(Microsoft Entra)のアクセストークンを直接クライアントに渡してもいい?
A. 一般的にはおすすめできません。Azure AD のアクセストークンは有効期限が長く、権限も広くなりがちです。 代わりに、バックエンドでそのトークンを使って Speech の短期トークンに交換し、ブラウザには短命なトークンだけを渡す構成が推奨されます。
チェックリスト:実装前に確認したい項目
最後に、本記事で紹介した内容をもとにチェックリストを整理します。プロジェクトのレビュー資料としてもそのまま使える形にしています。
| チェック項目 | 状態 | メモ |
|---|---|---|
| クライアントにサブスクリプションキーを一切置いていない | □ 未対応 / □ 対応済 | 環境変数・設定ファイルも含めて確認 |
| バックエンドにトークン配布 API(認証必須)を用意した | □ 未対応 / □ 対応済 | /api/speech-token など |
| Key Vault + マネージド ID でサブスクリプションキーを取得している | □ 未対応 / □ 対応済 | マネージド ID に Secret Get だけ付与 |
issueToken で短期トークンを発行している | □ 未対応 / □ 対応済 | リージョンとエンドポイント URL の確認 |
Angular 側で SpeechConfig.fromAuthorizationToken を利用している | □ 未対応 / □ 対応済 | fromSubscription を使っていないか確認 |
| トークンの期限前更新・レート制限・監査ログ・CORS 制御を実装済み | □ 未対応 / □ 対応済 | 運用チームと連携して要件を整理 |
まとめ:Angular + Azure Speech の安全な定番パターン
本記事では、SPA(Angular)から Azure Speech の音声・アバター機能を安全に使うための設計を、
- サブスクリプションキーをブラウザに一切出さない
- バックエンドに「トークン配布 API」を用意する
- Key Vault + マネージド ID で秘密情報を安全に取得する
- Speech SDK は
fromAuthorizationTokenで初期化する
という 4 つの軸から解説しました。
このパターンさえ押さえておけば、テキスト読み上げ・音声認識・音声アバターといった機能を、ビジネス要件や UI デザインに合わせて安心して拡張していけます。これから Angular で Azure Speech を本番導入する方は、まずはシンプルな「トークン配布 API」から実装を始めてみてください。

コメント