2026年07月30日
Experience Cloudサイトで「パスワードリセット」「本人確認」「限定コンテンツへの一時アクセス」といった機能を実装しようとすると、多くの場合まず思いつくのが「メールにワンタイムリンクを送って、クリックしたら手続きを完了させる」という仕組みです。ところが、実際に設計を始めると、次のような壁にぶつかることが少なくありません。
with sharing/without sharingをどう設計すべきか、セキュリティ面で不安が残る。この記事は、こうした「監査性は欲しいが、セキュリティと運用の手間もちゃんと考えたい」という悩みに対して、カスタムオブジェクトを使ったトークン管理という一つの解決策を、発行・検証・メール送信・掃除処理まで一気通貫で示すことを目的としています。
パスワードリセットや本人確認のように、後から追跡する必要がある業務では、一時キャッシュよりもカスタムオブジェクトで発行履歴をレコードとして残す方式が適しています。
・発行履歴・使用履歴がレコードとして残るため、監査ログとして活用できる
・レポートやダッシュボードで発行状況を可視化できる
・SOQLでの柔軟な検索・集計が可能
一方で、期限切れレコードを掃除するバッチ処理を別途用意する必要がある点はデメリットです。この記事では、その掃除処理まで含めた一連の実装を紹介します。
Access_Token__c という名前でカスタムオブジェクトを作成します。項目は以下の通りです。
| API参照名 | 型 | 用途 |
|---|---|---|
| Token_Value__c | テキスト(暗号化 or 外部ID) | トークン文字列そのもの |
| Related_Record_Id__c | テキスト(18桁) | 紐づく対象レコードのID |
| Expires_At__c | 日時 | 有効期限 |
| Used_At__c | 日時 | 使用済みになった日時(未使用ならnull) |
| Purpose__c | テキスト or 選択リスト | 用途(パスワードリセット、本人確認など) |
Token_Value__cは外部IDかつ大文字小文字を区別する設定にしておくと、SOQL検索の一意性が担保しやすくなります。また、このオブジェクトはトークンという性質上、ゲストユーザーから直接参照されるべきではないため、オブジェクト自体の共有設定は非公開にし、Apexはwithout sharingで必要な範囲だけアクセスさせる設計にします。
public without sharing class TokenObjectService {
private static final Integer DEFAULT_TTL_MINUTES = 30;
public static String issueToken(Id targetRecordId, String purpose) {
String tokenValue = generateRandomToken();
Access_Token__c tokenRecord = new Access_Token__c(
Token_Value__c = tokenValue,
Related_Record_Id__c = targetRecordId,
Expires_At__c = System.now().addMinutes(DEFAULT_TTL_MINUTES),
Purpose__c = purpose
);
insert tokenRecord;
return tokenValue;
}
private static String generateRandomToken() {
Blob randomBlob = Crypto.generateAesKey(256);
return EncodingUtil.convertToHex(randomBlob);
}
}
検証時には「存在するか」「期限内か」「未使用か」の3点をチェックし、条件を満たせば使用済みにマークします。
public without sharing class TokenObjectService {
public static Id validateAndConsumeToken(String tokenValue) {
List<Access_Token__c> tokens = [
SELECT Id, Related_Record_Id__c, Expires_At__c, Used_At__c
FROM Access_Token__c
WHERE Token_Value__c = :tokenValue
LIMIT 1
];
if (tokens.isEmpty()) {
return null; // トークンが存在しない
}
Access_Token__c token = tokens[0];
if (token.Used_At__c != null) {
return null; // 使用済み
}
if (token.Expires_At__c < System.now()) {
return null; // 期限切れ
}
token.Used_At__c = System.now();
update token;
return (Id) token.Related_Record_Id__c;
}
}
ポイントは、期限切れ・使用済み・存在しないの3パターンすべてを同じnullで返している点です。呼び出し元(LWC側)にどのパターンで失敗したかを詳細に伝えてしまうと、悪意のある第三者にトークンの状態を推測する手がかりを与えてしまうため、ユーザー向けメッセージは意図的に一律「このリンクは無効です」としています。
public with sharing class TokenController {
@AuraEnabled
public static Boolean consumeToken(String token) {
Id recordId = TokenObjectService.validateAndConsumeToken(token);
return recordId != null;
}
}
public without sharing class TokenEmailService {
public static void sendTokenEmail(String email, Id targetRecordId, String purpose) {
String token = TokenObjectService.issueToken(targetRecordId, purpose);
String url = buildTokenUrl(token);
Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
mail.setToAddresses(new String[] { email });
mail.setSubject('お手続きのご案内');
mail.setPlainTextBody(
'以下のリンクから30分以内にお手続きください。\n\n' + url
);
Messaging.sendEmail(new Messaging.SingleEmailMessage[] { mail });
}
private static String buildTokenUrl(String token) {
String siteBase = String.isNotBlank(Site.getBaseSecureUrl()) ? Site.getBaseSecureUrl() : Site.getBaseUrl();
String baseUrl = String.isNotBlank(siteBase) ? siteBase : Url.getOrgDomainUrl().toExternalForm();
return baseUrl + '/verify?token=' + EncodingUtil.urlEncode(token, 'UTF-8');
}
import { LightningElement, wire } from 'lwc';
import { CurrentPageReference } from 'lightning/navigation';
import consumeToken from '@salesforce/apex/TokenController.consumeToken';
export default class TokenVerify extends LightningElement {
isChecked = false;
isValid = false;
@wire(CurrentPageReference)
getPageReference(pageRef) {
const token = pageRef && pageRef.state && pageRef.state.token;
if (token) {
this.checkToken(token);
}
}
async checkToken(token) {
try {
this.isValid = await consumeToken({ token });
} catch (e) {
this.isValid = false;
} finally {
this.isChecked = true;
}
}
}
<template>
<template if:true={isChecked}>
<template if:true={isValid}>
<div class="success-message">認証が完了しました。</div>
</template>
<template if:false={isValid}>
<div class="error-message">このリンクは無効か、有効期限が切れています。</div>
</template>
</template>
</template>
カスタムオブジェクト方式では自動で消えないため、定期的に古いレコードを削除するバッチが必要です。監査目的で一定期間残したい場合は、即削除ではなく「一定期間経過後に削除」という設計にすると良いでしょう。
global class ExpiredTokenCleanupBatch implements Database.Batchable<SObject>, Schedulable {
// 監査目的で期限切れから7日間は残す
private static final Integer RETENTION_DAYS = 7;
global Database.QueryLocator start(Database.BatchableContext bc) {
DateTime cutoff = System.now().addDays(-RETENTION_DAYS);
return Database.getQueryLocator([
SELECT Id FROM Access_Token__c
WHERE Expires_At__c < :cutoff
]);
}
global void execute(Database.BatchableContext bc, List<Access_Token__c> scope) {
delete scope;
}
global void finish(Database.BatchableContext bc) {
}
global void execute(SchedulableContext sc) {
Database.executeBatch(new ExpiredTokenCleanupBatch());
}
}
これをScheduled Apexとして毎日深夜に実行するようスケジュール設定しておけば、レコードが際限なく溜まり続けることを防げます。
Access_Token__c自体はゲストユーザーに直接アクセスさせず、必ずApexコントローラー経由(with sharingのラッパークラス)でアクセスさせることで、オブジェクトレベルのセキュリティを担保します。Experience Cloudでユーザーにメールを送信し、リンク経由で手続きを完了させる仕組みは、カスタムオブジェクトでトークンを管理することで、発行履歴・使用履歴を証跡として残せます。パスワードリセットや本人確認など、追跡可能性が求められる業務ではこの方式が安全です。