Link Protection
Verify Dynamo-signed links on your backend — signature, replay, expiry, and user binding — before granting a reward.
When Dynamo sends a link to a user — for example a reward link in a broadcast — anyone who obtains the URL could try to open it: the original recipient, but also someone it was forwarded to, or someone who fabricated it outright. Link Protection lets your backend tell the difference: Dynamo signs every protected link, and you verify the signature — plus replay, expiry, and user binding — before granting anything.
Everything on this page runs on your backend. Dynamo signs the link when it's clicked; verification — and the policy around it, like how long a token stays valid — is yours.
How it works
Dynamo sends the link. The message the user receives contains a protected link with its unique link id.
The user clicks — Dynamo signs. At click time, Dynamo mints the signed
token — the link, its query params, the link id, the recipient's user id when
known, and the click's timestamp — and the user lands on your site with it
attached. Every click gets a fresh token; the d10xLinkId stays the same.
Your backend verifies. Run the seven checks below. Any failure means the link is invalid — don't grant.
Grant — and consume. On success, grant the reward and consume the link
id — for good. If you use the Audience API and the token
carried no cuid, identify the user so future
links arrive already bound to them.
Anatomy of a protected link
When a user clicks a protected link, they arrive at your site with two query parameters attached:
https://play.example.com/rewards?promo=spring50&d10x_link_id=a1b2c3&d10xlt=eyJhbGciOiJIUzI1NiIs...| Parameter | What it is |
|---|---|
d10x_link_id | A unique id for the link — the same across every click, and the basis for single-use enforcement. |
d10xlt | The link token: a JWT signed with HMAC-SHA256 (HS256), keyed by your organization's API secret — minted fresh on every click. |
The token is signed with the same secret you use to sign API requests — there is no separate Link Protection secret. It must live server-side only, never in a browser or app bundle.
The token
Verifying d10xlt (with HS256 pinned) yields this payload:
{
"d10xLinkId": "a1b2c3",
"cuid": "user_123",
"originalLink": "https://play.example.com/rewards",
"originalQueryParams": [{ "k": "promo", "v": "spring50" }],
"iat": 1754745600
}| Field | Type | Meaning |
|---|---|---|
d10xLinkId | string | Unique id for the link, identical across clicks. Must match the d10x_link_id query param. |
cuid | string? | Your user id for the recipient, when Dynamo already knows it (learned from a previous identify). Absent otherwise. |
originalLink | string | The link the token was issued for, query params stripped. |
originalQueryParams | { k, v }[] | The link's original query params at issuance. |
iat | number | Issued-at for this click, in seconds since epoch — each click carries a fresh iat. |
There is no exp claim — jwt.verify only proves authenticity. Expiry is
your policy: enforce your own TTL from iat (step 5 below).
Verifying a link
Run these checks in order; any failure means the link is invalid. Steps 1–4 verify what Dynamo signed; steps 5–7 apply your policy.
| # | Check | Blocks |
|---|---|---|
| 1 | The d10xlt JWT verifies with your API secret, algorithm pinned to HS256. | Forged links |
| 2 | The d10x_link_id query param equals the token's d10xLinkId. | Token swapping |
| 3 | The landing URL, query stripped, equals originalLink. | Replaying a token on a different URL |
| 4 | Every originalQueryParams entry is still present with the same value. | Tampered params |
| 5 | now − iat is within your TTL. | Stale links |
| 6 | The d10xLinkId was never consumed before (atomic check-and-set). | Replay & link sharing |
| 7 | The token's cuid, when present, matches the logged-in user. | Granting to the wrong account |
A few details worth knowing:
- URL matching (step 3).
originalLinkis the normalized URL form — compare againstnew URL(link)withsearchcleared and.toString(), as in the reference below, rather than raw string manipulation. - Param matching (step 4). The baseline check is one-directional: params recorded in the token must survive unchanged, while extra appended params (e.g. UTM tags) are tolerated. You can be stricter if your links should never gain params.
- TTL (step 5). Dynamo provides only
iat, stamped at click time — the TTL only needs to cover the hop from the click to your backend, so keep it short. The reference uses 5 minutes. - Single use (step 6). Consume the link id atomically (a conditional
insert / check-and-set) — a read followed by a separate write lets two
concurrent clicks both pass. Retain consumed ids forever: every click mints
a fresh, unexpired token for the same
d10xLinkId, so expiry alone never makes a consumed id safe to forget.
When the token has no cuid
A missing cuid means Dynamo doesn't yet know which of your users this recipient
is. Grant as usual — verification doesn't depend on it, and step 7 simply has nothing
to check.
If your organization uses the Audience API, this is also the moment to report the mapping with an identify call, passing the link id from the token and your id for the logged-in user:
{ "d10xLinkId": "a1b2c3", "userId": "user_123" }Once identified, future protected links for that user carry their cuid, and step 7
binds every link to the account it was sent to. If you don't use the Audience API,
skip this section.
Reference implementation
import * as jwt from 'jsonwebtoken'
export interface D10xLTPayload {
d10xLinkId: string
cuid?: string // your user id, when Dynamo already knows the mapping
originalLink: string // the link the token was issued for, query stripped
originalQueryParams: { k: string; v: string }[]
iat: number // issued at, seconds since epoch
}
export enum ED10xLTQueryParams {
D10X_LINK_TOKEN = 'd10xlt',
D10X_LINK_ID = 'd10x_link_id',
}
const TTL_SECONDS = 5 * 60 // your policy — how long a link stays valid
export async function isValidLink(link: string): Promise<boolean> {
const url = new URL(link)
// 1. Verify the signature (HS256 pinned)
const token = url.searchParams.get(ED10xLTQueryParams.D10X_LINK_TOKEN)
if (!token) return false
const payload = decodePayload(token, await getDynamoSecret())
if (!payload) return false
// 2. The d10x_link_id param must match the token
if (url.searchParams.get(ED10xLTQueryParams.D10X_LINK_ID) !== payload.d10xLinkId) {
return false
}
// 3. The URL, query stripped, must be the one the token was issued for
const params = [...url.searchParams].map(([k, v]) => ({ k, v }))
url.search = ''
if (url.toString() !== payload.originalLink) return false
// 4. Every original query param must still be present, unchanged
// (extra params — e.g. UTM tags — are tolerated; tighten if you need to)
for (const p of payload.originalQueryParams) {
if (!params.some((q) => q.k === p.k && q.v === p.v)) return false
}
// 5. Enforce your TTL from iat
const now = Math.floor(Date.now() / 1000)
if (now - payload.iat > TTL_SECONDS) return false
// 6. Single use — atomically consume the link id
if (!(await consumeD10xLinkId(payload.d10xLinkId))) return false
// 7. Bind to the user
const userId = await getLoggedInUserId()
if (payload.cuid) {
if (payload.cuid !== userId) return false
} else {
// Optional — only if you use the Audience API: report the mapping
// so future links carry the cuid
await sendIdentify(payload.d10xLinkId, userId)
}
return true // the link is valid — grant the reward
}
function decodePayload(token: string, secret: string): D10xLTPayload | undefined {
try {
return jwt.verify(token, secret, { algorithms: ['HS256'] }) as D10xLTPayload
} catch {
return undefined // invalid or forged token
}
}
// ——— Implement these for your platform ———
/** Your Dynamo API secret, from your secret manager. Server-side only. */
async function getDynamoSecret(): Promise<string> {
throw new Error('TODO: load your Dynamo API secret')
}
/** Atomically mark the link id used — and keep it forever. Return false if it
* was already consumed. */
async function consumeD10xLinkId(d10xLinkId: string): Promise<boolean> {
throw new Error('TODO: conditional insert into your used-links store')
}
/** The user viewing the link, by the id you identify them to Dynamo with. */
async function getLoggedInUserId(): Promise<string> {
throw new Error('TODO: resolve the logged-in user')
}
/** Optional (Audience API users): POST /v1.0/user/identify
* with { d10xLinkId, userId }. */
async function sendIdentify(d10xLinkId: string, userId: string): Promise<void> {
throw new Error('TODO: call the Audience API identify endpoint')
}Security checklist
- Verify server-side only. The secret signs both your API requests and these tokens — it must never reach a browser or mobile app.
- Pin the algorithm to
HS256when callingjwt.verify. - Consume link ids atomically — and retain them forever. Every click mints a fresh, unexpired token for the same id, so expiry never makes a consumed id safe to forget.
- Keep the TTL short. A protected link is meant to be clicked once, right away.
- Fail closed. Any check that can't be evaluated is a failed check.