Skip to content

Opt-In Token

This example demonstrates how DCB can be leveraged to replace a Read Model when implementing a Double opt-in

Challenge

A Double opt-in process that requires users to confirm their email address before an account is created

Traditional approaches

  • Stateless: Store required data and expiration timestamp in an encrypted/signed token

    This works, but it can lead to very long tokens

  • Persisted token: The server generates and stores a unique token, tied to the specified email address. When the email address is confirmed, the token is verified and invalidated (e.g., deleted).

    This method allows the tokens to be short but adds infrastructure overhead and complexity, and may result in stale or unused tokens accumulating over time

DCB approach

With DCB, a short token (i.e. OTP) can be generated on the server and stored with the data of the initial Event (SignUpInitiated).

With that, a dedicated Decision Model can be created that verifies the token. The token is invalidated as soon as the sign up was finalized (SignUpConfirmed Event)

Feature 1: Simple One-Time Password (OTP)

model "Opt-in token"

// Types
tag type EmailAddress = string
tag type Otp = string

// Events
event SignUpInitiated { tag emailAddress: EmailAddress, tag otp: Otp, name: string }
event SignUpConfirmed { tag emailAddress: EmailAddress, tag otp: Otp, name: string }

// Projections
projection SignUpPending (tag emailAddress: EmailAddress, tag otp: Otp): boolean = false {
  on SignUpInitiated => set true
}

projection SignUpName (tag emailAddress: EmailAddress, tag otp: Otp): string = null {
  on SignUpInitiated => set event.data.name
}

projection OtpUsed (tag emailAddress: EmailAddress, tag otp: Otp): boolean = false {
  on SignUpConfirmed => set true
}

// Commands
command ConfirmSignUp(emailAddress: EmailAddress, otp: Otp) {
  alias pending = SignUpPending(emailAddress, otp)
  alias used = OtpUsed(emailAddress, otp)
  alias name = SignUpName(emailAddress, otp)

  require pending is true
    else reject "No sign-up is pending for this code"
  require used is false
    else reject "Code was already used"

  emit SignUpConfirmed { emailAddress, otp, name }

4 scenarios scenarios { scenario "Confirm SignUp for non-existing OTP" { when ConfirmSignUp { emailAddress: "john.doe@example.com", otp: "000000" } then rejected "No sign-up is pending for this code" } scenario "Confirm SignUp for OTP assigned to different email address" { given SignUpInitiated { emailAddress: "john.doe@example.com", otp: "111111", name: "John Doe" } when ConfirmSignUp { emailAddress: "jane.doe@example.com", otp: "111111" } then rejected "No sign-up is pending for this code" } scenario "Confirm SignUp for already used OTP" { given SignUpInitiated { emailAddress: "john.doe@example.com", otp: "222222", name: "John Doe" } and SignUpConfirmed { emailAddress: "john.doe@example.com", otp: "222222", name: "John Doe" } when ConfirmSignUp { emailAddress: "john.doe@example.com", otp: "222222" } then rejected "Code was already used" } scenario "Confirm SignUp for valid OTP" { given SignUpInitiated { emailAddress: "john.doe@example.com", otp: "444444", name: "John Doe" } when ConfirmSignUp { emailAddress: "john.doe@example.com", otp: "444444" } then SignUpConfirmed { emailAddress: "john.doe@example.com", otp: "444444", name: "John Doe" } } }
}

ConfirmSignUp reads 2 types, 2 tags

Query ItemEvent TypesTags
pendingSignUpInitiatedEmailAddress:{emailAddress}, Otp:{otp}
usedSignUpConfirmedEmailAddress:{emailAddress}, Otp:{otp}
nameSignUpInitiatedEmailAddress:{emailAddress}, Otp:{otp}

AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read

Tags of the appended events: EmailAddress:{emailAddress}, Otp:{otp}

Feature 2: Expiring OTP

A requirement might be to expire tokens after a given time (for example: 60 minutes). The example can be easily adjusted to implement that feature:

Note

The notation has no clock, so time is data: SignUpInitiated stores when the OTP expires (expiresAt), and the current time is passed to ConfirmSignUp as now. For simplicity, both are plain numbers of minutes. Typically, they are timestamps, with expiresAt calculated from the time the sign-up was initiated.

model "Opt-in token (expiring)"

// Types
tag type EmailAddress = string
tag type Otp = string
type Minute = integer

// Events
event SignUpInitiated {
  tag emailAddress: EmailAddress
  tag otp: Otp
  name: string
  expiresAt: Minute
}

event SignUpConfirmed { tag emailAddress: EmailAddress, tag otp: Otp, name: string }
13 unchanged lines // Projections projection SignUpPending (tag emailAddress: EmailAddress, tag otp: Otp): boolean = false { on SignUpInitiated => set true } projection SignUpName (tag emailAddress: EmailAddress, tag otp: Otp): string = null { on SignUpInitiated => set event.data.name } projection OtpUsed (tag emailAddress: EmailAddress, tag otp: Otp): boolean = false { on SignUpConfirmed => set true }
projection OtpExpiresAt (tag emailAddress: EmailAddress, tag otp: Otp): Minute = 0 { on SignUpInitiated => set event.data.expiresAt } // Commands command ConfirmSignUp(emailAddress: EmailAddress, otp: Otp, now: Minute) { alias pending = SignUpPending(emailAddress, otp) alias used = OtpUsed(emailAddress, otp) alias expiresAt = OtpExpiresAt(emailAddress, otp) alias name = SignUpName(emailAddress, otp) require pending is true else reject "No sign-up is pending for this code" require used is false else reject "Code was already used" require expiresAt > now else reject "Code has expired" emit SignUpConfirmed { emailAddress, otp, name }
5 scenarios scenarios { scenario "Confirm SignUp for non-existing OTP" { when ConfirmSignUp { emailAddress: "john.doe@example.com", otp: "000000", now: 100 } then rejected "No sign-up is pending for this code" } scenario "Confirm SignUp for OTP assigned to different email address" { given SignUpInitiated { emailAddress: "john.doe@example.com", otp: "111111", name: "John Doe", expiresAt: 160 } when ConfirmSignUp { emailAddress: "jane.doe@example.com", otp: "111111", now: 100 } then rejected "No sign-up is pending for this code" } scenario "Confirm SignUp for already used OTP" { given SignUpInitiated { emailAddress: "john.doe@example.com", otp: "222222", name: "John Doe", expiresAt: 160 } and SignUpConfirmed { emailAddress: "john.doe@example.com", otp: "222222", name: "John Doe" } when ConfirmSignUp { emailAddress: "john.doe@example.com", otp: "222222", now: 100 } then rejected "Code was already used" } scenario "Confirm SignUp for valid OTP" { given SignUpInitiated { emailAddress: "john.doe@example.com", otp: "444444", name: "John Doe", expiresAt: 160 } when ConfirmSignUp { emailAddress: "john.doe@example.com", otp: "444444", now: 100 } then SignUpConfirmed { emailAddress: "john.doe@example.com", otp: "444444", name: "John Doe" } } scenario "Confirm SignUp for expired OTP" { given SignUpInitiated { emailAddress: "john.doe@example.com", otp: "333333", name: "John Doe", expiresAt: 99 } when ConfirmSignUp { emailAddress: "john.doe@example.com", otp: "333333", now: 100 } then rejected "Code has expired" } }
}

ConfirmSignUp reads 2 types, 2 tags

Query ItemEvent TypesTags
pendingSignUpInitiatedEmailAddress:{emailAddress}, Otp:{otp}
usedSignUpConfirmedEmailAddress:{emailAddress}, Otp:{otp}
expiresAtSignUpInitiatedEmailAddress:{emailAddress}, Otp:{otp}
nameSignUpInitiatedEmailAddress:{emailAddress}, Otp:{otp}

AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read

Tags of the appended events: EmailAddress:{emailAddress}, Otp:{otp}

Conclusion

This example demonstrates, how DCB can be used to implement a simple double opt-in functionality without the need for additional Read Models or Cryptography