Hey! Tim Baker here, CTO at Hoops Finance. My job is to make the scary parts of infrastructure boring. Authentication is one of those parts. If it’s weak, nothing else matters; if it’s complicated, it fails in invisible ways. This cycle we rebuilt auth around Secure Remote Password (SRP‑6a), enforced memory‑hard derivation, passkey linking, and a smart-account signer model. In practice: your password never leaves the browser, we never see derived secrets, and we can still prove you’re you—cryptographically, not by hope.

Below is what changed, why we did it, and the tradeoffs we accepted to ship something secure and practical.

The problem: “hope TLS holds” isn’t a strategy

Traditional email/password auth is a trust exercise. You post a secret to a server, the server hashes and compares, and we all hope the TLS termination point, our app server, the logs, and a dozen proxies don’t leak anything they shouldn’t. It works until it doesn’t.

The target state for us was clear:

  • Passwords never leave the browser.

  • Zero-knowledge the server verifies a derived proof instead of storing a hash.

  • Memory-hard key derivation argon2 makes offline brute forcing near impossible

  • Backwards compatibility so existing users don’t get locked out.

  • Traceable sessions end-to-end for debugging and security audits

  • Hardware Security (Passkeys, FIDO) become first class auth methods.

  • On-Chain policy enforcement without ever exporting your privkey.

SRP-6a + WebAuthn + SEP10 + Smart Accounts. The design gets us where we want without inventing a whole new religion. (since kalepail has already done it )

Hoopy, Hoops Finance’s mascot, securing the platform

What Changed This Cycle

1) SRP-6a, e2e with Mutual Proofs

We implemented the full SRP-6a flow with mutual authentication. The browser derives a salted private exponent and verifier from your credentials and transmits the parameters to the server. The server then stores only that. At login, both sides compute a shared session key and prove knowledge of it. If either side lies, the proof fails.

  • Browser-side crypto: WebCrypto + argon2-browser WASM. (but it can still fall back if the device can’t run argon2.)

  • Parameters: 2048-bit safe prime (RFC 5054), generator g=2, SHA-256.

    Routes (proxying to our auth service):

POST /auth/srp/register { email, saltHex, verifierHex, kdf } POST /auth/srp/login/start { email, Ahex } -> { saltHex, Bhex, kdf, nonce } POST /auth/srp/login/finish { email, Ahex, M1hex, nonce } -> { accessToken, refreshToken, serverProofHex }

The proxy layer keeps SRP complexity out of components and centralizes error handling and API key auth. We reject invalid group elements (`A mod N = 0` or `B mod N = 0`) and always consume the challenge (success or fail).

2) Enforced KDF Policy (Argon2id Primary, PBKDF2 Fallback)

SRP is only as strong as the secret derivation behind it. We added Argon2id in the browser using argon2-browser with mobile-friendly defaults:

  • Argon2id (mem ~32–64 MiB, time 2–3, parallelism 1) on modern hardware.

  • PBKDF2‑SHA256 (~200k iterations) for constrained devices.

  • Per‑account KDF stored; login reuses the registered parameters (no downgrade ambiguity).

  • Server can instruct migration if we upgrade defaults.

Older devices fall back to PBKDF2 at 150k–300k iterations. Servers can enforce KDF policy during login to nudge weak clients up to stronger parameters. If you’re on a ten-year-old phone, you still sign in; if you’re on modern hardware, you get the full weight of memory-hard derivation.

3) Legacy Migration (Get old users off of hashed password)

Legacy bcrypt login (still supported during migration) returns:

{ success: true,
  needsSrpMigration: true,
  policy: { kdfPrimary: {...}, kdfFallback: {...} } }

Client derives SRP params and calls /auth/srp/register. Server now:

  • Saves SRP state

  • Clears passwordHash immediately

  • Rotates refresh tokens (invalidate old sessions)

5) Passkey Account Linking and Auth

Multi‑passkey support via WebAuthn (SimpleWebAuthn server):

Stored credential shape:

{ credId, publicKey, counter, transports, deviceType, backedUp, aaguid, nickname, createdAt }

Duplicate credId rejected; challenge consumed on all outcomes. This set the stage for passkey sign‑in + 2FA.

6) Sessions you can actually reason about

We introduced a sessionId that flows everywhere:

  • UserType / UserResponseType (API layer)

  • JWT payloads (token transport)

  • Frontend Fingerprints (Device Fingerprinting, VisitorID)

This sounds boring. It isn’t. When something goes sideways, being able to correlate “this JWT” with “this database session” and “this visitor callback run” cuts mean-time-to-truth drastically. The session tracking helps keep users safe, but it also helps keep our service safe from criminals. Similarly to how we score assets with risk, we score accounts. This is an internal process which we can’t go into a ton of detail on, but by ranking the risk of an account, it helps us provide more services to the legitimate users.

7) Type safety and ergonomics

  • Strict guards around ephemeral values (A ≢ 0 mod N, B ≢ 0 mod N).

  • Constant-time comparisons where it counts.

  • Refactored session callbacks with clearer error boundaries.

  • File-path comments across components (navigation matters in a growing repo).

  • Removed stray console.log, standardized formatting, and retired substr in favor of slice.

8) Challenge Discipline Everywhere

SRP login, passkey link/auth, and SEP‑10 each create a single‑use, TTL‑bound record:

{ id, type, // 'srp.login' | 'webauthn.link' | 'webauthn.auth' | 'sep10.link' userId, issuedAt, expiresAt, srp?, webauthn?, sep10? }

All are consumed in finally blocks—no replay, and a deterministic audit trail.

6) Smart Account Signer Model (In Progress / Integrated)

Pluggable signer types:

  • Ed25519 (classic Stellar key)

  • Secp256r1 (passkey-style)

  • Secp256k1 (Eth wallets like metamask)

  • Policy (on‑chain contract enforcing contextual limits) [Allow or block various actions based on ledger-state and rules defined in the policy]

Pseudocode:

for ctx in contexts:
  require some signer allowed-by-limits(ctx)

for sig in signatures:
  match sig.kind:
    Ed25519    => verify_ed25519(sig, ctx_hash)
    Secp256k1  => verify_ECDSA(sig, ctx_hash)
    Secp256r1  => verify_webauthn(sig.authenticatorData, sig.clientDataJSON)
    Policy     => call policy_contract(policyAddr, contexts)
type Context = { chainId, op, params, nonce, expiresAt }
type Signature =
  | { kind: 'ed25519', bytes }
  | { kind: 'secp256k1', bytes }
  | { kind: 'webauthn', authenticatorData, clientDataJSON }
  | { kind: 'policy', proof? }

type Signer =
  | { kind: 'ed25519', pubkey }                  // 32B
  | { kind: 'secp256k1', address }               // 20B EOA
  | { kind: 'webauthn', pubkeyP256, rpIdHash }   // P-256, rpId bound
  | { kind: 'policy', contractAddr }             // on-chain policy

function hashContext(ctx: Context): Bytes32
  return domain_separated_hash(ctx)              // e.g., keccak256(encode(ctx))

function allowedByLimits(signer: Signer, ctx: Context): bool
  // local limit checks (per-signer caps, method allowlist, time/window, nonce)
  // may also consult cached ledger state if needed
  return true

function verifySig(signer: Signer, sig: Signature, ctxHash: Bytes32): bool
  switch signer.kind:
    case 'ed25519':
      return verify_ed25519(sig.bytes, ctxHash, signer.pubkey)

    case 'secp256k1':
      // ecrecover over ctxHash (already keccak’d/domain-separated)
      return ecrecover(ctxHash, sig.bytes) == signer.address

    case 'webauthn':  // secp256r1 / P-256
      return verify_webauthn(
        authenticatorData=sig.authenticatorData,
        clientDataJSON=sig.clientDataJSON,
        expectedChallenge=ctxHash,
        rpIdHash=signer.rpIdHash,
        pubkey=signer.pubkeyP256
      )

    case 'policy':
      // defer to on-chain policy with full ledger-aware logic
      return call policy_contract(signer.contractAddr).allow(ctx)

function verifyAuthorization(contexts: Context[], signers: Signer[], signatures: Signature[]): bool
  ctxHashCache = map<Context, Bytes32>()
  for ctx in contexts:
    ctxHash = ctxHashCache.getOrElse(ctx, hashContext(ctx))
    ok = false
    for i in 0..signers.length-1:
      s = signers[i]
      sig = signatures[i]  // pairing strategy can vary; map by signer ID in practice
      if allowedByLimits(s, ctx) and verifySig(s, sig, ctxHash):
        ok = true
        break
    if not ok:
      return false
  return true

Result: passkeys become on‑chain capable actors without exporting keys or relying on a server proxy signer.

7) Client-Side Key Custody (Stellar / SEP‑10)

  • Browser generates Ed25519 keypair.

  • Derives KEK from SRP secret (+ future passkey hmac-secret) via domain separation.

  • Encrypts private key with random DEK; wraps DEK with KEK.

  • Server stores ciphertext + metadata only.

  • SEP‑10 signing happens locally; server only verifies.

How it actually works (the “short” version)

Hoopy, Hoops Finance’s mascot, abstracting the math for you

Registration

  1. Client derives x via Argon2id/PBKDF2 from (email || password).

  2. Computes v = g^x mod N.

  3. Sends { saltHex, verifierHex, kdf }.

Login

  1. Client: generate A; server returns { salt, B, kdf, nonce }.

  2. Both derive session key K.

  3. Client sends M1 = H(A, B, K).

  4. Server verifies, returns M2 = H(A, M1, K) (serverProofHex), issues tokens.

At no point does the password or a reusable password hash cross the wire.

You get zero-knowledge properties without browser plugins, vendor lock-in, or magic HSMs. Just careful math and a little WASM.

Tradeoffs and why we accepted them

Tradeoffs (and Mitigations)

Concern

Tradeoff

Mitigation

Argon2id cost on old hardware

Higher latency

PBKDF2 fallback with strong iterations

Increased code surface (SRP + WebAuthn)

More complexity

Strict validation + unified challenge lifecycle

Potential cloned passkey counters

Risk of silent duplication

Counter verification + optional 2FA step-up

Replay attempts

Attack surface

Single-use, consumed challenges

Immediate hash purge (no rollback)

Less recovery cushion

Thorough test harness + explicit migration flag

Why this matters to you (and to us)

  • Nothing to leak: No plaintext password, no legacy bcrypt hash after SRP enrollment.

  • Expensive offline guessing: Memory‑hard primary path; graceful fallback.

  • Auditability: Each auth flow tied to a deterministic challenge id + request id.

  • Future‑proof: Passkeys and on‑chain policies slot in without re‑architecting.

  • User control: Private keys for chain operations never hit our servers.

This is the difference between “we hope” and “we can prove it.”

Implementation Notes (Engineers Will Ask)

  • Group: RFC 5054 2048‑bit safe prime, g=2, SHA‑256.

  • Hex Discipline: Length‑preserving; inputs validated (regex) before use.

  • Proof Checks: Reject zero-value residue classes (A mod N, B mod N).

  • KDF Policy: Server clamps unsupported parameters; client re-derives if instructed.

  • Refresh Rotation: On SRP migration & SRP reset confirm; stale refresh tokens pruned.

  • Logging: Structured [srp/login:finish] ... / [verifyPasskeyLogin] ... with truncated hex segments—no secret material.

  • Passkey Metadata: transports, deviceType, backedUp persisted for adaptive auth.

What’s next

SRP + Argon2id gives us a solid foundation to layer on passkeys and Stellar account linking without duct tape:

  • WebAuthn (Passkeys): hardware-backed, biometric-friendly auth as a first-class option.

  • Stellar (SEP-10) linking: signed challenges to bind accounts and enable on-chain actions with proper provenance.

  • Risk-based auth: choose flows dynamically based on method strength and context, not superstition.

  • Passkey assertion login + passkey-as-2FA gate.

  • KEK (Key Encryption Key) derivation blending passkey hmac-secret when present.

  • Policy modules: rate limit, time-lock, n‑of‑m quorum.

  • Dashboard surfacing signer health + recent auth events.

  • Risk‑adaptive flows: escalate from SRP → passkey → policy as context demands.

When that lands, users will log in with no passwords at all, or combine passkeys + SRP + chain signatures for high-assurance operations — still with clean fallbacks for older devices.

Users will be able to see all sessions, all auth methods, all OAuth accounts that are linked, and all their smart wallets and what they have authorized to transact on these accounts. All in one place quick and easy.

Closing

Authentication should be invisible when it works and unforgiving when it fails. By moving to SRP with enforced KDF policy, immediate legacy hash purge, hardware-backed passkey linking, and on‑chain-enforceable signers, we replaced “we hope” with “we can prove it.”

We spent significant effort on the authentication system for Hoops, but this is because we want the system to be secure, as well as easy to use. We want to support any type of safe authentication methods that the user wants to use. From your favorite stellar wallets such as Lobstr, Freighter, to the wallets you use to access other chains such as MetaMask. This should provide a breakthrough user experience that makes it easier for everyone.

By providing auditable auth records for each user you will be secure in knowing that you can immediately invalidate any auth method in the case of emergency, such as your phone getting stolen. For secure operations we will require multi-factor authentication. And when we are using the safest methods in the industry, this will help users stay safe and happy.

Sign up at app.hoops.finance/signup

If you’re integrating with our API or building on top of our dashboards, the immediate impact is stability: fewer auth edge cases, clearer errors, and a foundation that won’t make us rewrite everything when we add passkeys and Stellar identity.

Happy shipping.

— Tim

Keep Reading

No posts found