Blog
ブログ

2026年07月30日

【7月技術ブログ】Experience Cloudで期限付きトークンURLをメール送信する実装

こんな課題に直面していませんか?

Experience Cloudサイトで「パスワードリセット」「本人確認」「限定コンテンツへの一時アクセス」といった機能を実装しようとすると、多くの場合まず思いつくのが「メールにワンタイムリンクを送って、クリックしたら手続きを完了させる」という仕組みです。ところが、実際に設計を始めると、次のような壁にぶつかることが少なくありません。

  • 「あのリンク、本当に使われたのか?」が分からない: 一時キャッシュだけでトークンを扱っていると、有効期限が切れた瞬間にデータが消えてしまい、後から「いつ発行され、いつ使用されたか」を追跡できない。
  • ゲストユーザーに何をどこまで見せていいか判断に迷う: Experience Cloudサイトは未認証のゲストユーザーがアクセスするため、トークンを扱うオブジェクトの共有設定やApexのwith sharing/without sharingをどう設計すべきか、セキュリティ面で不安が残る。
  • エラーメッセージの出し方で悩む: 「期限切れ」「使用済み」「そもそも存在しない」を別々のメッセージで返すべきか、それとも一律にすべきか、判断基準が分からない。
  • データが溜まり続ける問題への対処が後回しになりがち: トークンをレコードとして残す設計にした場合、「いつ・どうやって消すのか」という掃除処理の設計を忘れがちで、後から気づいて慌てて対応することになる。

この記事は、こうした「監査性は欲しいが、セキュリティと運用の手間もちゃんと考えたい」という悩みに対して、カスタムオブジェクトを使ったトークン管理という一つの解決策を、発行・検証・メール送信・掃除処理まで一気通貫で示すことを目的としています。

解決策:カスタムオブジェクトによるトークン管理

パスワードリセットや本人確認のように、後から追跡する必要がある業務では、一時キャッシュよりもカスタムオブジェクトで発行履歴をレコードとして残す方式が適しています。

カスタムオブジェクト方式のメリット

・発行履歴・使用履歴がレコードとして残るため、監査ログとして活用できる

・レポートやダッシュボードで発行状況を可視化できる

・SOQLでの柔軟な検索・集計が可能

 

一方で、期限切れレコードを掃除するバッチ処理を別途用意する必要がある点はデメリットです。この記事では、その掃除処理まで含めた一連の実装を紹介します。

1. カスタムオブジェクトの設計

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で必要な範囲だけアクセスさせる設計にします。

2. トークン発行処理(Apex)

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. トークン検証処理(Apex)

検証時には「存在するか」「期限内か」「未使用か」の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側)にどのパターンで失敗したかを詳細に伝えてしまうと、悪意のある第三者にトークンの状態を推測する手がかりを与えてしまうため、ユーザー向けメッセージは意図的に一律「このリンクは無効です」としています。

4. Aura Enabledラッパーとメール送信

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');
}

5. LWC側での受け取りと検証

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>

6. 期限切れレコードの掃除バッチ

カスタムオブジェクト方式では自動で消えないため、定期的に古いレコードを削除するバッチが必要です。監査目的で一定期間残したい場合は、即削除ではなく「一定期間経過後に削除」という設計にすると良いでしょう。

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でユーザーにメールを送信し、リンク経由で手続きを完了させる仕組みは、カスタムオブジェクトでトークンを管理することで、発行履歴・使用履歴を証跡として残せます。パスワードリセットや本人確認など、追跡可能性が求められる業務ではこの方式が安全です。

このページの先頭へ