Passkey Authentication for Salesforce Digital Experience (Community Cloud) – Part 2

Share This Post

Throughout this article, we will guide you on how to implement Passkey Authentication for Salesforce Digital Experience (Community Cloud). This blog article is divided into 2 parts. In previous section we’ve covered Passkey registration flow and now we have user credentials saved in our Salesforce, let’s go through how we can authenticate using them.


To trigger authentication we’ll need to call navigator.credentials.get(options) method with “publicKey” parameter and following options:

  • challenge – randomly generated string that will be signed by User’s device
  • timeout – after which Passkey authentication will be automatically canceled
  • allowCredentials – which allows us to restrict the list of the acceptable credentials for retrieval. We don’t need it for B2C users, so we’ll leave array empty
  • userVerification – which indicates whether user needs to go through Biometrics
  • rpId – our community Host, we’ll get it from experienceInfo we’ve used during registration

Challenge generation during registration was easy, because we had a user’s session and could utilize Platform Cache, which doesn’t work in the context of Guest user. How can we securely generate it? We could generate a random string and then store it in a custom table, but then we’ll have to make sure that we invalidate tokens when used and delete unused ones once after a certain time… There should be a better way…

Well, what is the purpose of the challenge?


As a cryptographic protocol, Web Authentication is dependent upon randomized challenges to avoid replay attacks. Therefore, the values of both PublicKeyCredentialCreationOptions.challenge and PublicKeyCredentialRequestOptions.challenge MUST be randomly generated by Relying Parties in an environment they trust (e.g., on the server-side), and the returned challenge value in the client’s response MUST match what was generated. This SHOULD be done in a fashion that does not rely upon a client’s behavior, e.g., the Relying Party SHOULD store the challenge temporarily until the operation is complete. Tolerating a mismatch will compromise the security of the protocol.

That is what reCAPTCHA token does as well!

Each reCAPTCHA user response token is valid for two minutes, and can only be verified once to prevent replay attacks. If you need a new token, you can re-run the reCAPTCHA verification. If you’re protecting an action with reCAPTCHA, make sure to call execute when the user takes the action rather than on page load.

To add reCAPTCHA to our community we’ll need to add following code to our Head Markup:

					<!-- Google reCAPTCHA v3 -->
    document.addEventListener('grecaptchaExecute', function(e) {
        grecaptcha.execute('YOUR_SITE_KEY', {action: e.detail.action}).then(function(token) {
            document.dispatchEvent(new CustomEvent('grecaptchaVerified', {'detail': {response: token, action:e.detail.action}}));

<script src=""></script>


And then subscribe to an grecaptchaVerified event in our LWC component:

					addRecaptchaVerificationHandler() {
        let that = this;

        document.addEventListener("grecaptchaVerified", function(e) {
            if (e.detail.action !== 'passkeyLogin') {

            let challenge = e.detail.response;

            var options = {
                // The challenge is produced by Google reCAPTCHA
                challenge: string2ArrayBuffer(challenge),
                timeout: 300000,  // 5 minutes
                allowCredentials: [],
                userVerification: "required",

            navigator.credentials.get({ "publicKey": options })
                .then(function (assertion) {
                    let authInfo = {
                        userId: arrayBuffer2String(assertion.response.userHandle),
                        signature: arrayBuffer2Base64(assertion.response.signature),
                        authenticatorData: arrayBuffer2Base64(assertion.response.authenticatorData), 
                        clientDataJSON: new TextDecoder().decode(assertion.response.clientDataJSON),
                        startUrl: that.currentPageReference.state['startURL']

                    loginWithPasskey({ authInfo: JSON.stringify(authInfo) }).then(result => {
              , "_self");
                    }).catch(err => {
                }).catch(function (err) {
                    // No acceptable credential or user refused consent. Handle appropriately.


On successful execution of navigator.credentials.get() method we are getting a set of authentication parameters:

  • id – Id of credentials that provided this response
  • clientDataJSON – contains the JSON-compatible serialization of client data passed to the authenticator, like challenge. It will be used during verification.
  • authenticatorData – ArrayBuffer containing information about authentication, which also will be used during verification
  • signature – ArrayBuffer containing signature of authenticatorData + SHA-256(clientDataJSON) using authenticators Private Key
  • userHandle – users Id

Having all this we can securely verify our customer!

clientDataJSON.challenge verification with Google will eliminate possibility of replay attacks and userHandle alongside id will help us retrieve User_Credential__c record with related Public Key and Algorithm for signature verification.


Since authentication is happening under Guest user and we don’t want to give it any access to User_Credential__c object our Apex will run with “without sharing” keyword and SOQL with “WITH SYSTEM_MODE”. This will ensure that it still has access to necessary information without exposing it externally.

Our Apex Controller method to authenticate user would look like this:

					public static String loginWithPasskey(AuthenticationInfo authInfo) {
        try {
            ClientData cData = (ClientData) JSON.deserialize(authInfo.clientDataJSON, ClientData.class);

            if(cData.type != 'webauthn.get') {
                throw new PasskeyVerificationException('Unexpected verification response type: ' + cData.type);

            //Validating challenge
            if(cData.challenge != null && Recaptcha.verify(cData.challenge)) {
                throw new PasskeyVerificationException('Invalid challenge');

            User_Credential__c credential = [SELECT User__r.Username, Public_Key__c, Public_Key_Algorithm__c
                                             FROM User_Credential__c 
                                             WHERE User__c = :authInfo.userId AND Credential_Id__c = :authInfo.credentialId
                                             WITH SYSTEM_MODE 
                                             LIMIT 1];

            //signature structure:
            String hexAuthenticatorData = EncodingUtil.convertToHex(EncodingUtil.base64Decode(authInfo.authenticatorData));
            String hexClientData = EncodingUtil.convertToHex(Crypto.generateDigest('SHA-256', Blob.valueOf(authInfo.clientDataJSON)));
            Blob data = EncodingUtil.convertFromHex(hexAuthenticatorData + hexClientData);

            Boolean verified = Crypto.verify(credential.Public_Key_Algorithm__c, 

            if(verified) {
                String accessToken = getUserAccessToken(credential.User__r.Username);
                String startUrl = String.isBlank(authInfo.startUrl) ? '/' : authInfo.startUrl;

                //Pass access token to frontdoor to create a web session 
                return Utils.COMMUNITY_VF_URL + '/secur/frontdoor.jsp?sid=' + accessToken + '&retURL=' + startUrl; 
            } else {
                throw new PasskeyVerificationException('Invalid signature / credential details');
        } catch (Exception ex) {
            System.debug(ex.getMessage() + '\n' + ex.getStackTraceString());
            throw new AuraHandledException('Failed to authenticate with Passkey');


Looking through the code you probably noticed that after we verify signature we generate access token and return url pointing to frontdoor.jsp, this process is covered in detail in our “How to generate Magic Links for Community Users” blog post, in short it creates a valid web session for a given user, so that user can proceed into community.

Right, we are all set now! Our users can add Passkey to their accounts and easily use them to authenticate with our Digital Experience site!You can play with this implementation in a scratch or sandbox by grabbing a code from this repository:

It will return us a Promise that will resolve into True or False depending on the feature availability. It is best to trigger this function within connectedCallback() of our component, so that we could hide or show the Registration button.

About the Author
Nazim Aliyev, CTO @ Nubessom

Nazim Aliyev, CTO @ Nubessom

Salesforce Consultant & Architect, Business Applications, Business Processes and Technology Consulting, Entrepreneurship. More than 15 years working on the CRM and Salesforce Ecosystems.

Let´s talk about your challenge!

    In order to provide you the content requested, we need to store and process your personal data. If you consent to us storing your personal data for this purpose, please tick the checkbox below.

    You can unsubscribe from these communications at any time. For more information on how to unsubscribe, our privacy practices, and how we are committed to protecting and respecting your privacy, please review our Privacy Policy.

    Need more Inspiration? keep reading Our related content

    Blog Article

    Generate Magic Links for your users in Experience Cloud

    A magic link, or one-time login (OTL) or passwordless login link, is a unique, time-sensitive URL sent to a user’s registered email address as a secure means of authentication. Learn how to generate them in Experience Cloud (Community Cloud)
    Read More »