A logistics invite is a doorway into shipment data, so bot resistance changes the design: verify the person and the invitation before creating a user, then score the device before granting a session. Short answer: keep the invite pending, bind the verification ceremony to that invite, and create the account only after every check passes.
That ordering matters. The old mental model is “accept invite, insert user, clean up if verification fails.” The safer model is a state machine: issued -> claimed -> identity_verified -> user_created, with an explicit rejected terminal state. No row in the user table exists during the first three states.
What should invite acceptance authentication verify before user creation?
Start with the invitation, not an email address supplied by the browser. Store a cryptographically random, single-use token as a hash, plus the tenant, role, expiry, and intended email. On acceptance, look up the hash, lock the record, and consume it in the same transaction that records the verification result. A copied link should not be replayable, and changing tenantId in a request body must not move an invite between customers.
Identity proof is a separate decision. Depending on the risk tier, that can be an email possession check, an enterprise identity-provider assertion, or a stronger document and liveness workflow. The API should receive a provider-neutral verification result with an issuer, subject, assurance level, and timestamp. Do not treat a matching display name as proof.
The device signal belongs in the decision, too. In a delivery network, a burst of accepts from one fingerprint, impossible travel between depot locations, or automation-like timing can indicate abuse even when the email is real. A fingerprint is a risk input, not an identity. Keep its retention and purpose narrow, and avoid turning it into a permanent person identifier.
A small Node.js transaction that keeps the boundary clear
The following service shows the important boundary. verifyIdentity is an adapter around your chosen verifier; the database transaction owns the invite state and user creation.
type Verification = {
issuer: string;
subject: string;
assurance: "email" | "idp" | "high";
verifiedAt: Date;
};
type Invite = {
id: string;
tenantId: string;
email: string;
role: string;
tokenHash: string;
expiresAt: Date;
status: "issued" | "claimed" | "identity_verified" | "accepted" | "rejected";
};
async function acceptInvite(input: {
token: string;
deviceRisk: number;
identityAssertion: unknown;
}) {
const tokenHash = await sha256(input.token);
return db.transaction(async (tx) => {
const invite: Invite | null = await tx.invites.lockByTokenHash(tokenHash);
if (!invite || invite.expiresAt <= new Date() || invite.status !== "issued") {
throw new Error("invite_not_acceptable");
}
const verification = await verifyIdentity(input.identityAssertion);
if (!verification || input.deviceRisk >= 80) {
await tx.invites.markRejected(invite.id);
throw new Error("verification_required");
}
await tx.invites.markIdentityVerified(invite.id, verification);
const user = await tx.users.create({
tenantId: invite.tenantId,
email: invite.email,
role: invite.role,
identityIssuer: verification.issuer,
identitySubject: verification.subject
});
await tx.invites.markAccepted(invite.id, user.id);
return user;
});
}
The transaction is deliberately boring. That is good. A unique constraint on (tenantId, identityIssuer, identitySubject) prevents duplicate membership, while an idempotency key lets a retried request return the already-created user instead of issuing a second one. Return generic failure text to the browser; send the detailed reason, invite ID, risk score, and correlation ID to your audit stream.
I once treated the invite token and the device cookie as equivalent evidence in a review. They are not. The token proves control of a link; the device signal estimates abuse likelihood. Mixing those meanings made a high-risk automation path look authenticated.
Instrument the decision, not just the endpoint
Logs should answer “why did this invite become a user?” without storing raw tokens or biometric payloads. Emit structured fields such as invite_id, tenant_id, verification_issuer, assurance, device_risk_band, decision, and latency_ms. Hash or truncate identifiers according to your retention policy.
Metrics expose drift: acceptance rate by assurance level, rejection rate by risk band, replay attempts, duplicate-key conflicts, and time from claim to verification. Alert on a sudden rise in replays or on one fingerprint accepting invites across many tenants. A dashboard that only counts HTTP 200 responses will miss the abuse story.
Ship the audit trail.
For example, an acceptance event can carry event_version: 1, a server-generated occurred_at, and a decision reason such as identity_verified_device_review. Keep the raw assertion out of that event. A redacted event can be joined to the invite record through a random correlation ID, allowing an on-call engineer to reconstruct the sequence without being able to redeem the link. In a multi-tenant logistics system, include the depot or route scope only when it is already part of the authorization model; extra location fields create tempting side channels. Write the event before returning success, and monitor the lag between the database commit and the audit append. If the append is asynchronous, a durable queue with bounded retries is easier to reason about than a best-effort log call hidden inside the request handler. That detail is where incident reviews either find a clear timeline or find a blank space.
The catch is operational friction. A strict device threshold can block a legitimate dispatcher behind a shared carrier NAT, while a permissive threshold invites scripted acceptance. This flow is not suitable when you cannot provide a trustworthy identity signal or a review path; use a managed enterprise identity provider and manual approval for high-risk tenants instead. Your mileage may vary with privacy rules and regional employment law, so have counsel validate retention and biometric handling before rollout.
Testing the unhappy paths
Test the state machine with property-based or table-driven cases: an expired token, a replayed token, a mismatched tenant, a valid identity with a high device risk, a verifier timeout, and two concurrent accepts. Assert that none of the rejected cases leaves a user row behind. Then replay the same idempotency key and confirm one stable result.
For production, canary the policy in observe-only mode. Compare the proposed decision with human review, tune thresholds by route and tenant, and keep a kill switch that requires verification for every invite. I’m not sure one universal score can serve every depot; the evidence from your own false-positive queue should decide.
The design rule is compact: authenticate the invitation, verify identity, score abuse risk, and only then create membership. Keeping those facts separate makes failures explainable and gives operators a useful lever when the threat changes.
Top comments (0)