DEV Community

CarterHughes6853
CarterHughes6853

Posted on

Device Risk in Invite Acceptance Authentication Before You Create a User

Short answer: verify the invitee and their device risk before writing a user row; commit the acceptance and identity together, then issue a session. In a logistics console, that ordering keeps a scripted invite-claimer from becoming a tenant member while still giving a legitimate dispatcher a predictable retry path.

The invite is an offer, not an identity. A mailbox check says who controls an address; a device fingerprint and request history add evidence about automated abuse. Neither should be treated as a license to create an account before the server has made its decision.

How should invite acceptance authentication verify identity before user creation?

Model acceptance as a state machine with four durable states: pending, verified, accepted, and expired. The pending record contains the tenant, intended role, invite identifier, a hash of the one-time code, expiry, send count, and verification-attempt count. It does not contain a reusable credential in clear text. The browser carries only an opaque identifier between steps, so changing a hidden email or tenant field cannot change the principal being accepted.

For a fleet-operations product, collect the device fingerprint as a risk signal, not as a password. A new browser, a headless user agent, a burst of attempts from one network range, and ten invites claimed in a minute should raise a score or require a stronger challenge. The score must not silently replace identity verification: a low-risk device with the wrong code still fails, and a valid code from a high-risk device can be held for review without creating a user.

I once assumed that “verified email” and “safe login” were the same event. They are not. A replayed invite can pass the first check while an automation farm consumes the tenant's seats. Keeping verification and acceptance as separate transitions made that distinction visible in metrics and in the audit record.

A transaction boundary that survives retries

The application should perform the final transition in one database transaction. Lock the invite row, confirm that it is verified and unexpired, evaluate the device-risk decision stored for that acceptance attempt, insert an acceptance record with a unique invite key, and create the user and tenant membership. Commit those writes before creating a session. A timeout after commit is then a retry problem, not an identity-creation race.

Here is the core ordering in Go. Names are deliberately generic; the important contract is that every check is server-side and the acceptance key is unique.

type Acceptance struct {
    InviteID       string
    DeviceScore    int
    VerificationID string
}

func acceptInvite(ctx context.Context, a Acceptance) (string, error) {
    tx, err := store.Begin(ctx)
    if err != nil {
        return "", err
    }
    defer tx.Rollback(ctx)

    invite, err := tx.LockInvite(ctx, a.InviteID)
    if err != nil {
        return "", err
    }
    if invite.State != "verified" || invite.ExpiresAt.Before(time.Now()) {
        return "", errors.New("invite cannot be accepted")
    }
    if !riskPolicyAllows(a.DeviceScore) {
        return "", errors.New("additional verification required")
    }

    if existing, ok := tx.AcceptanceForInvite(ctx, a.InviteID); ok {
        return existing.UserID, tx.Commit(ctx)
    }
    userID, err := tx.CreateUser(ctx, invite.Email)
    if err != nil {
        return "", err
    }
    if err := tx.AddMembership(ctx, userID, invite.TenantID, invite.Role); err != nil {
        return "", err
    }
    if err := tx.MarkAccepted(ctx, a.InviteID, userID, a.VerificationID); err != nil {
        return "", err
    }
    return userID, tx.Commit(ctx)
}
Enter fullscreen mode Exit fullscreen mode

The unique acceptance constraint is the guard against two browser tabs. If the second transaction sees an existing acceptance, it returns that user rather than attaching a different identity. I've seen this matter when a dispatcher double-clicks a link on a weak warehouse connection: the first commit succeeds, the response is lost, and the retry arrives while the first request is still finishing. Session creation should also accept an idempotency key, because the response can disappear after the commit.

Keep it boring.

Rate limits, signals, and an SLO that means something

Rate-limit code sends, code guesses, and invite claims independently. Three sends per hour and five guesses per code are plausible starting points, but the correct values depend on the threat model and load test; I'm not sure they fit a global fleet with shared NAT addresses. Expire codes quickly, invalidate the previous code on resend, and keep counters in server-side storage so a new browser cannot reset them. Return the same generic denial for a bad code and a blocked score, with a 429 response only when a documented rate limit is crossed; clients can back off without learning which identity check failed.

Measure more than endpoint latency. A useful SLO set includes the percentage of accepted invites that receive a session, the percentage held by risk policy, verification denials by reason class, and time spent pending. Track device-fingerprint cardinality and the ratio of claims to invitations per tenant. Alert on a sudden rise in accepted invites from a small fingerprint cluster: that is an abuse signal even when every individual request returns a 2xx.

Log a correlation ID, state transition, tenant, and policy outcome. Redact codes, raw fingerprints, and email addresses. OWASP advises treating authentication material and account-existence signals as sensitive; generic verification errors prevent an attacker from learning which addresses are registered.

Rollback and the limits of this pattern

Before rollout, test the ugly sequences: a valid code at the expiry boundary, two tabs claiming one invite, a dropped response after commit, and a device score that changes between verification and acceptance. The invariant is simple: no unverified or disallowed invite creates a user, and every accepted invite has exactly one audit chain.

Rollback means disabling new acceptance transitions while allowing already verified invites to expire or finish. Do not delete users as a rollback tactic; that can destroy audit evidence and leave external memberships inconsistent. A small operator tool should revoke an invite, inspect its state history, and reissue a code without exposing the old one.

The catch is that device fingerprints are probabilistic and can be shared, reset, or blocked by privacy controls. This pattern is not suitable when regulation requires government-ID proof, hardware-backed credentials, or an enterprise identity provider; insert that stronger verification before the same acceptance transaction. Stick with a simpler email-only flow when the tenant is low risk and abuse cost is negligible, because operating a scoring pipeline then adds on-call work without a useful security return.

Teams can build the state machine in the application for exact tenant and role semantics, or delegate credential handling to an identity system and keep invitation metadata in a separate store. The first choice owns expiry, abuse controls, and recovery tooling. The second choice reduces credential operations but introduces synchronization and audit-boundary work. Neither removes the need for a server-enforced identity check before user creation.

Decision Works well when Cost to carry
Application-owned state Tenant roles and device policy change often On-call ownership for expiry, abuse controls, and recovery
External identity system Credential operations are already mature Invitation state and audit evidence must stay synchronized
Manual review gate High-value accounts justify human inspection Lower throughput and a queue that needs an explicit SLO

That is the boundary.

References

Top comments (0)