Sign-In-With-X: que compra pagar una vez una ruta x402
Todo post sobre x402 arranca con la misma premisa: el agente paga cada llamada. Sign-In-With-X es la extension que la suspende en silencio, y casi nadie ha leido que cubre esa suspension.
x402 es un protocolo por request. El cliente pide, el servidor responde 402, el cliente firma un pago, el servidor sirve. Repetir para siempre. El modelo es limpio, y tambien es caro cuando un agente golpea el mismo endpoint doscientas veces en una hora.
La extension Sign-In-With-X — SIWX — es la valvula de escape. Una wallet que ya pago un recurso firma un mensaje en lugar de un pago, y el servidor la deja entrar gratis. La especificacion de la extension lo describe en una linea: los clientes "prueban el control de una direccion de wallet firmando un mensaje de challenge, lo que permite a los servidores identificar usuarios recurrentes y saltarse el pago para direcciones que ya pagaron".
Esa frase contiene mucha superficie sin especificar. Que recurso, exactamente. Durante cuanto tiempo. Cuantas veces. Que pasa si la prueba firmada se copia de un log. Clonamos el repositorio, leimos la spec, leimos las tres implementaciones de referencia y reprodujimos el comportamiento que decide esas preguntas.
Que auditamos, y en que commit
El repositorio del protocolo vive ahora bajo la organizacion de la Linux Foundation como x402-foundation/x402 — 6.572 estrellas al momento de escribir. Auditamos el commit 2cc7e9a, con fecha 2026-09-04, cuyo asunto es chore(go): release (#3359). Las versiones en ese arbol son @x402/extensions 2.25.0, el paquete Python x402 2.22.0 y el modulo Go etiquetado go/v2.9.0.
SIWX es una de las siete entradas del registro oficial de extensiones, junto a Bazaar, Builder Code, dos extensiones de gas sponsoring, Payment Identifier y Signed Offers & Receipts. Es una de las cuatro unicas con implementacion en los tres SDKs, y por eso la divergencia entre lenguajes merece un post. Recorrimos el registro completo en la auditoria de la capa de extensiones; esto es el zoom sobre la unica extension que cambia la economia de una ruta paga.
Su historia es corta y esta bien documentada en el arbol. Los hook adapters de TypeScript aterrizaron el 2026-05-15 (#2304), el SDK de Python recibio la extension el 2026-05-21 (#2393), Go el 2026-06-20 (#2485), y ambos ports no-TypeScript fueron reescritos el 2026-08-18 (#3192 y #3193). En el log se ven tres arreglos de seguridad: #2859 del 2026-07-15 ato la validacion de dominio a un origin configurado en vez del header Host, #2933 del 2026-07-23 corrigio la verificacion Ed25519 en Solana frente a puntos de orden pequeno, y #3133 del 2026-08-13 hizo que el cliente se niegue a firmar un challenge que no coincida con el origin de la respuesta que lo produjo.
El mecanismo en una pasada
SIWX es una extension Server-Client. El facilitator no participa en ningun momento, y eso ya dice algo: nada de este flujo toca una cadena, y nada de este flujo se liquida. Es autenticacion atornillada sobre un protocolo de pagos.
El servidor anuncia soporte dentro del cuerpo del 402, bajo la clave sign-in-with-x del objeto extensions. El challenge lleva los campos estandar de CAIP-122 — domain, uri, version, nonce, issuedAt, los opcionales expirationTime, notBefore, statement, resources — mas un array supportedChains que le dice al cliente que tipos de firma acepta el servidor.
// cuerpo de la respuesta 402, abreviado
{
"x402Version": "2",
"accepts": [ /* payment requirements */ ],
"extensions": {
"sign-in-with-x": {
"info": {
"domain": "api.example.com",
"uri": "https://api.example.com/quote",
"nonce": "a1b2c3d4e5f67890a1b2c3d4e5f67890",
"issuedAt": "2026-09-04T10:30:00.000Z",
"expirationTime": "2026-09-04T10:35:00.000Z"
},
"supportedChains": [
{ "chainId": "eip155:8453", "type": "eip191" }
]
}
}
}
El cliente elige la primera cadena que coincide con su signer, construye un mensaje EIP-4361 para wallets EVM o un mensaje Sign-In-With-Solana para Solana, lo firma, y devuelve todo el payload mas address y signature como JSON en base64 en un header SIGN-IN-WITH-X. La verificacion EVM soporta EOAs por recuperacion ECDSA y smart accounts por EIP-1271 y EIP-6492, lo que significa que la extension sirve tanto para smart accounts como para EOAs planas — al costo de una llamada RPC por verificacion.
Del lado servidor, la verificacion son cuatro pasos: parsear el header, validar los campos del mensaje, verificar la firma y despues — paso cuatro, citado textual de la spec — "el Servidor comprueba si la address recuperada pago previamente por el recurso solicitado. Esta es logica especifica de la aplicacion".
Esa ultima frase es donde termina todo el modelo de seguridad de una ruta paga. La spec se detiene. Los SDKs no, y lo que embarcan es el contrato real.
El challenge no es un challenge
Si relees la lista de campos asumirias un challenge-response clasico: el servidor acuna un nonce, lo recuerda y solo acepta una firma que lo devuelva. No es lo que ocurre.
El servidor genera un nonce fresco en cada 402 — en Go, dieciseis bytes aleatorios en hex — y despues lo olvida. No hay ningun almacen de nonces emitidos en todo el arbol. Peor para el encuadre de challenge, la extension Go declara explicitamente que campos puede cambiar el cliente:
func (e *ServerExtension) DynamicInfoFields() []string {
return []string{"nonce", "issuedAt", "expirationTime"}
}
Esos tres campos quedan exentos de la validacion de echo que en otro caso obliga al cliente a devolver los valores del servidor. Asi que el cliente puede acunar su propio nonce y su propio timestamp. La validacion, en los tres lenguajes, se reduce a: domain igual al host del origin configurado, origin de uri igual al origin configurado, issuedAt no en el futuro y con menos de cinco minutos, y coherencia de expirationTime y notBefore si estan presentes.
El nonce solo se comprueba contra reuso, y solo si el backend de storage se apunta. La spec es explicita sobre la fuerza de ese requisito: "Nonce: MUST ser unico. El Servidor SHOULD registrar nonces usados para prevenir ataques de replay". Un SHOULD, no un MUST.
Ese encuadre importa especialmente para agentes. Los agentes loguean headers HTTP. Los agentes enrutan por proxies y gateways. Los agentes entregan trazas a pipelines de observabilidad. Una firma de pago es de un solo uso por construccion — la cadena rechaza la segunda. Un header SIWX no lo es, salvo que el servidor haya decidido que lo sea.
Tres stores en memoria, tres posturas frente al replay
Cada SDK define una interfaz de storage con dos metodos obligatorios y dos opcionales. El par obligatorio registra y consulta quien pago. El par opcional registra y consulta nonces usados. La documentacion de TypeScript enuncia la regla sin rodeos: "Ambos metodos deben implementarse juntos — implementar solo uno lanzara un error en el arranque".
Cada SDK embarca ademas una implementacion en memoria, y los tres servidores de ejemplo del repositorio la usan. Aqui los tres ports divergen.
Go la implementa. InMemoryStorage lleva un nonces map[string]struct{} junto al set de pagos, y satisface la interfaz opcional NonceStorage. El servidor hace una asercion de tipo en runtime, la encuentra y comprueba cada nonce entrante. La proteccion contra replay viene activada por defecto.
TypeScript no. InMemorySIWxStorage tiene exactamente un campo, paidAddresses. Sin hasUsedNonce, sin recordNonce. El hook busca el metodo, no lo encuentra y salta la comprobacion. El ejemplo canonico del README del paquete — el camino de copy-paste de cualquiera que adopte la extension — instancia esa clase. La proteccion contra replay viene desactivada por defecto.
Python es un tercer caso, y es el interesante.
El guard de Python comprueba un metodo que no existe
Python declara el contrato de storage como un Protocol con cuatro metodos: has_paid, record_payment, has_used_nonce, record_nonce. El request hook aplica entonces la regla de todo-o-nada en tiempo de construccion:
has_used_nonce = callable(getattr(storage, "has_used_nonce", None))
has_record_nonce = callable(getattr(storage, "has_record_nonce", None))
if has_used_nonce != has_record_nonce:
raise ValueError(
"SIWxStorage nonce tracking requires both has_used_nonce and record_nonce "
"to be implemented"
)
La segunda linea sondea has_record_nonce. El metodo definido en el Protocol, documentado en la referencia de la extension y llamado cuarenta lineas mas abajo, es record_nonce. Ninguna clase de storage tendra jamas un atributo con el nombre sondeado, asi que has_record_nonce es permanentemente False.
Las consecuencias invierten el guard. Implementa la interfaz documentada correctamente — has_used_nonce y record_nonce — y la comparacion pasa a ser True != False, con lo que el hook se niega a construirse y tu servidor no arranca. No implementes ninguno de los dos y el hook se construye limpio con el registro de nonces desactivado en silencio. La unica configuracion que levanta es la que no tiene la defensa.
Lo reprodujimos contra el paquete publicado. Instala x402 2.22.0, entrega al request hook una clase de storage que implemente el Protocol exactamente, y despues entregale la clase en memoria que viene incluida:
# x402 version: 2.22.0
[Protocol-conformant storage] RAISED ValueError: SIWxStorage nonce tracking
requires both has_used_nonce and record_nonce to be implemented
[shipped InMemorySIWxStorage] hook created OK -> replay tracking active? False
Hay una salida — definir un metodo llamado literalmente has_record_nonce al lado del real, para que ambos sondeos den verdadero — pero nada en la documentacion lleva a nadie ahi. El desenlace realista es que un operador Python se choca con el ValueError, lo lee como "este storage esta mal", quita los metodos de nonce para que el error desaparezca y despliega sin proteccion contra replay creyendo lo contrario.
El typo esta en el arbol desde que la extension aterrizo en el SDK de Python el 2026-05-21, y sobrevivio a la reescritura completa del 2026-08-18. Sobrevive porque ningun test lo ejercita: la suite unitaria de Python para SIWX no menciona record_nonce ni has_used_nonce en ninguna parte. Los tests del servidor en Go asertan sobre HasUsedNonce directamente, y por eso Go es el port que funciona.
El permiso es un path, y no caduca nunca
El paso cuatro de la spec — "logica especifica de la aplicacion" — esta implementado de forma identica en los tres SDKs, y la implementacion es un test de pertenencia a un conjunto.
Al liquidar con exito, el settle hook toma la URL del recurso desde el payment payload, la reduce a URL.pathname y registra el par. En una peticion posterior con un header SIWX valido, el request hook pregunta si el payer recuperado esta en el conjunto de context.path. Ambos adaptadores HTTP de Python resuelven eso al path pelado: Flask devuelve request.path, FastAPI devuelve request.url.path. Ninguno incluye el query string.
Corrimos el settle hook real contra una liquidacion sintetica para ver que clave escribe:
# url del recurso pagado:
# https://api.example.com/quote?symbol=BTC&depth=50
stored keys: {'/quote': {'0xabc...0001'}}
De ahi se siguen tres propiedades, y ninguna esta documentada en la referencia de la extension.
El permiso cubre el path, no la peticion. Pagar por /quote?symbol=BTC&depth=50 guarda /quote, y toda peticion posterior a /quote con cualquier query resuelve a la misma clave. En una API REST donde el query string selecciona la parte cara del trabajo — el simbolo, el rango de fechas, el modelo, el presupuesto de tokens — una compra de la variante mas barata compra todas las variantes.
El permiso no tiene cardinalidad. Es un conjunto, no un contador. Una liquidacion equivale a peticiones ilimitadas despues. No existe la nocion de llamadas restantes en ninguna parte de la interfaz de storage.
El permiso no tiene caducidad. No hay timestamp en el registro ni camino de expulsion en ninguna de las tres implementaciones en memoria. Una wallet que pago una vez en mayo puede seguir entrando hoy. Si quieres una ventana de suscripcion, la escribes tu, en un backend de storage que aportas tu.
x402 pone precio a una peticion; SIWX concede un path
El protocolo de pagos es cuidadoso con la unidad de valor: un scheme, un monto, un asset, un recurso. En el momento en que SIWX registra ese pago, todo eso colapsa en un string y una direccion dentro de un conjunto.
El desajuste no es un bug de los SDKs — implementan lo que la spec delega. Es un limite de diseno que hereda todo seller, y la mayoria lo heredara copiando el ejemplo.
Que hace bien la extension
Una auditoria que solo lista brechas es una mala auditoria. SIWX hace varias cosas bien, y dos de ellas son cosas que esquemas comparables hacen mal.
El domain binding esta bien resuelto y fue endurecido a proposito. La spec exige que el servidor valide domain y el origin de uri contra su propio origin publico configurado, "no contra valores derivados de la peticion como el header Host". Los tres SDKs se niegan a construir el hook sin un origin explicito, y rechazan origins con credenciales, path, query o fragmento. El commit #2859 hizo ese cambio en julio frente al comportamiento anterior derivado del header; la misma disciplina aparece en direccion contraria en #3133, donde el cliente se niega a firmar un challenge cuyo origin no coincide con la respuesta que lo produjo. Ambas direcciones del replay cross-site quedan cerradas.
El caracter chain-agnostic es real y no nominal. El mismo header lleva firmas EIP-191 para cadenas EVM y firmas Ed25519 para Solana, despachadas por el namespace CAIP-2, de modo que un seller no mantiene dos caminos de auth.
Y los limites temporales se aplican de forma consistente: cinco minutos de antiguedad maxima por defecto en issuedAt, rechazo de timestamps futuros y respeto de expirationTime y notBefore cuando estan, con un codigo de fallo legible por maquina para cada comprobacion. Comparado con el bearer token promedio, una prueba auto-emitida de cinco minutos con una taxonomia explicita de fallos es una mejora real.
Lo que falta no es criptografia. Es la capa contable que va encima.
Que significa para LLM4Agents
Operamos un gateway compatible con OpenAI donde los agentes pagan por llamada en stablecoins. Cada ruta de LLM4Agents esta medida, y la medicion es el producto. Eso convierte a SIWX en una forma que no podemos adoptar tal cual, y en una forma con la que tenemos que saber hablar.
Adoptarla textual del lado seller seria una caida del modelo de negocio. Nuestra unidad de valor son tokens consumidos, no endpoints tocados. Un permiso permanente indexado por path aplicado a /v1/chat/completions significa que el primer pago compra inferencia ilimitada para siempre. El scheme upto existe precisamente porque el costo de una llamada es desconocido hasta que termina — lo cubrimos en la auditoria de metered billing — y SIWX colapsa toda esa historia de costo variable en un booleano.
Hay una version que si podemos adoptar, y es estrecha. SIWX encaja bien con artefactos, no con computo: un reporte de evaluacion terminado, un dataset cacheado, un artefacto generado que el comprador ya pago y puede volver a buscar. Volver a descargar un resultado guardado no deberia costar un segundo pago. Eso es exactamente para lo que se diseno la extension, y es donde la usariamos.
Del lado buyer el calculo es distinto y mayormente favorable. Cuando nuestros agentes pagan endpoints x402 ajenos, un seller que ofrece SIWX significa un pago en vez de doscientos, y nuestro cliente ya tiene la clave de firma. El costo es que el agente empieza a emitir una credencial reutilizable en cada peticion. Nuestro modelo de amenazas para eso es el que describimos en el threat model de agentes: todo header de larga vida que un agente carga es un header que un agente puede filtrar, via una tool call con prompt injection, un log verboso o un servidor MCP comprometido en la cadena.
La lectura estrategica es que SIWX es el punto donde x402 desarrolla sesion, y las sesiones son donde los protocolos de pago vuelven a ser auth web ordinaria. Trazamos ese limite en bearer contra walk-up: las credenciales bearer son correctas para contrapartes conocidas con cuenta, y el pago por request es correcto para desconocidos. SIWX es el camino de migracion entre ambos, ejecutado dentro del protocolo de pagos en vez de al lado. Cualquier gateway que pretenda sentarse entre agentes y sellers tiene que modelar los dos estados, y saber en cual esta cada llamada.
Como mantenerse en la frontera
En concreto, y por orden.
Primero, mandar el arreglo de Python upstream. El sondeo de has_record_nonce es una correccion de una palabra mas el test que lo habria cazado — un doble de storage que implemente el Protocol documentado, asertando que el hook se construye y que un nonce repetido es rechazado. Es la contribucion mas pequena posible con efecto real de seguridad, y pone nuestro nombre en la extension de la que dependemos.
Segundo, tratar todo permiso SIWX que emitamos como un lease, nunca como pertenencia a un conjunto. Nuestra implementacion de storage registra el payer, el recurso, una caducidad y un contador de llamadas restantes, y deniega ante cualquiera de las tres. La interfaz que definen los SDKs es un Protocol con dos metodos obligatorios; nada impide que la implementacion detras sea una tabla de entitlements de verdad. Lease por defecto: minutos, no meses.
Tercero, indexar los permisos por la identidad completa de la peticion, no por URL.pathname. Para un gateway, la clave correcta incluye el modelo y los parametros que mueven el precio. Donde una forma canonica sea incomoda, hashear la peticion normalizada y usar eso como clave. Indexar solo por path es aceptable para un archivo estatico; no lo es delante de inferencia.
Cuarto, del lado buyer, tratar el header SIWX como un secreto con el mismo manejo que una clave privada: nunca logueado, nunca trazado, nunca reenviado fuera del origin para el que fue acunado, y re-acunado por peticion en vez de cacheado. Cinco minutos de validez son suficientemente cortos solo si el header no se queda una semana en un almacen de trazas.
Quinto, emparejar SIWX con la extension payment-identifier alli donde la aceptemos. Los identificadores de idempotencia y el registro de nonces usados son la misma defensa apuntando a dos superficies de replay distintas, y adoptar una sin la otra deja abierta la mitad obvia. El registro de extensiones es lo bastante pequeno como para adoptarlo con coherencia y no a pedazos — el censo en la auditoria de extensiones lista las siete.
Sexto, y mas alla de nuestro propio despliegue: empujar para que la spec diga algo normativo sobre el permiso. El paso cuatro delega hoy todo el modelo de entitlement a "logica especifica de la aplicacion", y tres SDKs implementaron de forma independiente la version mas debil razonable. Un solo parrafo — los permisos SHOULD llevar caducidad, y SHOULD indexarse por la identidad de recurso con la que se puso precio al pago — moveria el default de todo el que copie el ejemplo, que es todo el mundo.
Pago por llamada, sin permisos permanentes
Un gateway compatible con OpenAI donde cada llamada se mide, se cotiza y se liquida en stablecoins — y donde una sesion nunca se convierte en un cheque en blanco.
Registra tu agente