This repository houses DIMO's Web3 auth pipeline: one binary (cmd/dauth)
serving two surfaces on one host (e.g. dauth.dimo.zone), together replacing the
heavily-forked Dex (DEX) previously used for Web3 login. The two surfaces are two
stages of one flow, routed by path prefix:
/siweproves "I control this Ethereum address." A client signs a Sign-In With Ethereum (EIP-4361) challenge and receives a short-lived RS256 JWT carrying its address./exchangeproves "this address may access this asset with these permissions." It takes a/siweaddress-control token from a registered developer license and, after checking on-chain/SACD access, mints a permission token scoped to a DIMO asset.
Both surfaces issue stateless, offline-verifiable tokens: each publishes its
own JWKS and OIDC discovery document under its prefix, signs with its own
RSA key, and keeps a distinct iss claim. Two separate keysets are the
security boundary — a token of one kind never verifies against the other
surface's JWKS. The only server-side state is sign-in's short-lived challenge
(nonce) store — kept in memory, or in Postgres to run multiple replicas (see
deployment); the exchange holds no state, and validating a token
never needs a server round-trip. The shared host is pure transport; nothing here
uses an OAuth2 authorization-code flow, refresh tokens, or a connector framework.
| Surface | Prefix | Issues | Default iss |
Published at |
|---|---|---|---|---|
| Sign-in | /siwe |
Address-control token (ethereum_address) |
https://dauth.dimo.zone/siwe |
…/siwe/keys |
| Exchange | /exchange |
Permission token (asset / permissions / cloud_events) |
https://dauth.dimo.zone/exchange |
…/exchange/keys |
The exchange also exposes a gRPC TokenExchangeService on its own port. The two
surfaces share Go packages — internal/keyset (signing), internal/oidc (JWKS +
discovery), and internal/httpmw (middleware) — but keep separate claims,
issuers, signing keys, and config (sign-in config is namespaced SIWE_*, the
exchange EXCHANGE_*). The public packages pkg/tokenclaims and pkg/grpc
are consumed by downstream repos.
PUBLIC_BASE_URL (e.g. https://dauth.dimo.zone) is the host both surfaces are
reachable at; it is the base of each published jwks_uri, decoupled from the
iss claims. The two surfaces are documented in full below: sign-in first, then
the exchange, followed by deployment and development notes.
SIWE challenge → signature verify → JWT, plus a standard validation surface. The canonical SIWE message is generated and stored server-side; the issued JWT is stateless.
client dauth
│ POST /siwe/challenge {address} │
│ ─────────────────────────────────▶ generate single-use nonce,
│ │ build EIP-4361 message, store it
│ ◀─────────────────────────────── { challenge, nonce, expires_at }
│ │
│ wallet personal_sign(challenge) │
│ │
│ POST /siwe/token {nonce, sig} │
│ ─────────────────────────────────▶ look up by nonce, consume
│ │ (single-use), verify sig (EOA/1271),
│ │ mint RS256 JWT
│ ◀─────────────────────────────── { token, token_type, expires_in }
The canonical SIWE message is generated and stored server-side, keyed by its nonce, so the bytes the wallet signs are exactly the bytes dauth verifies. The client returns the nonce; dauth looks up the stored entry, verifies the signature against the stored copy, and consumes the entry (single-use) — giving true replay protection. The store is the only server-side state; the issued JWT is stateless and offline-verifiable.
{ "address": "0x6E4…A1b" }The chain is fixed by the CHAIN_ID config.
An optional audience (string array) requests a specific aud for the issued
token, e.g. { "address": "0x6E4…A1b", "audience": ["step-ca"] }. Every value
must be on the SIWE_ALLOWED_AUDIENCES allow-list, or the challenge is rejected
with invalid_request (400). The audience is bound to the nonce at challenge
time and cannot be changed at /siwe/token. Omit it to receive the configured
default (JWT_AUDIENCE). This unblocks clients — e.g. step-ca certificate
enrollment — that validate the sign-in token as an id_token and require their
own client ID in aud. Response:
{
"challenge": "dauth.dimo.zone wants you to sign in with your Ethereum account:\n0x6E4…A1b\n\nSign in to DIMO.\n\nURI: https://dauth.dimo.zone\nVersion: 1\nChain ID: 137\nNonce: …\nIssued At: …\nExpiration Time: …",
"nonce": "…",
"expires_at": "2026-06-14T17:25:00Z"
}{ "nonce": "…", "signature": "0x1c8f…" }The wallet must personal_sign the exact challenge string; the client returns
the nonce from step 1 to identify it. Response:
{ "token": "eyJ…", "token_type": "Bearer", "expires_in": 3600 }Errors use OAuth-style codes: { "error": "invalid_grant", "error_description": "…" }.
invalid_request → 400, invalid_grant → 401, server_error → 503/500, 429 on
rate limit.
GET /siwe/.well-known/openid-configuration— OIDC discovery metadata.GET /siwe/keys(aliasGET /siwe/.well-known/jwks.json) — JWKS (RFC 7517).GET /siwe/swagger/— interactive OpenAPI docs for the sign-in endpoints.
GET /ping,GET /ready— health probes.GET /metrics— Prometheus metrics.
RS256, with kid in the header. Claims:
| claim | value |
|---|---|
iss |
configured issuer |
sub |
EIP-55 checksummed address |
aud |
configured audience(s), or an allow-listed value requested at /siwe/challenge (see below) |
iat / nbf / exp |
now / now / now + TOKEN_TTL |
jti |
random UUID |
ethereum_address |
EIP-55 checksummed address |
ethereum_address is the canonical claim downstream services read.
Point any validator at the issuer and JWKS. For example, din's attestation
server is configured with:
TOKEN_EXCHANGE_ISSUER=https://dauth.dimo.zone/siwe
TOKEN_EXCHANGE_KEY_SET_URL=https://dauth.dimo.zone/siwe/keys
and validates iss, RS256, and the ethereum_address claim — no dauth-specific
code required.
- EOA — ECDSA recovery (
ecrecover), verified locally. - Deployed smart accounts — EIP-1271
isValidSignature, checked against the account contract viaRPC_URL.
EIP-1271 requires RPC_URL. Without it, dauth verifies EOA signatures only.
Counterfactual (undeployed) smart accounts (EIP-6492) are not supported.
The variables below configure the sign-in surface and the process as a whole.
The exchange surface's variables are namespaced EXCHANGE_* (see its own
section); a few process-wide variables (PUBLIC_BASE_URL, HTTP_ADDRESS,
OPS_ADDRESS, LOG_LEVEL, ENVIRONMENT, DATABASE_URL) are shared.
| Variable | Required | Default | Notes |
|---|---|---|---|
PUBLIC_BASE_URL |
yes | — | Externally reachable origin, e.g. https://dauth.dimo.zone. Base of each surface's jwks_uri; also the SIWE uri and default SIWE_DOMAIN host. |
SIWE_ISSUER |
yes | — | Absolute URL, e.g. https://dauth.dimo.zone/siwe. The sign-in JWT iss. |
JWT_AUDIENCE |
yes | — | Comma-separated default aud value(s). |
SIWE_ALLOWED_AUDIENCES |
no | — | Comma-separated allow-list of aud values a caller may request at /siwe/challenge. Empty means no override is permitted. |
SIWE_SIGNING_KEY_1, SIWE_SIGNING_KEY_2, … |
yes | — | PEM RSA private keys, in order. _1 is the active signer. |
CHAIN_ID |
no | 137 |
Chain the sign-in is bound to (in the SIWE message). |
SIWE_DOMAIN |
no | PUBLIC_BASE_URL host |
Domain shown in the SIWE message. |
SIWE_STATEMENT |
no | Sign in to DIMO. |
Statement shown in the wallet. |
RPC_URL |
no | — | Ethereum RPC for EIP-1271; empty disables smart-account login. |
RPC_TIMEOUT |
no | 3s |
Bounds each EIP-1271 call. |
CHALLENGE_TTL |
no | 5m |
Challenge lifetime. |
TOKEN_TTL |
no | 1h |
Access-token lifetime. |
ALLOWABLE_TIME_SKEW |
no | 5m |
Clock skew tolerance. |
HTTP_ADDRESS |
no | 0.0.0.0:8080 |
Public listen address (both prefixes). |
OPS_ADDRESS |
no | 0.0.0.0:8081 |
Ops listen address. |
MAX_BODY_BYTES |
no | 16384 |
Request body cap. |
RATE_LIMIT_RPS / RATE_LIMIT_BURST |
no | 0 / 20 |
Per-IP limit; 0 disables. |
LOG_LEVEL |
no | info |
zerolog level. |
TLS_CERT_FILE / TLS_KEY_FILE |
no | — | In-process TLS; omit to terminate at the ingress. |
DATABASE_URL |
no | — | Postgres challenge store as a postgres://user:pass@host:5432/dauth?sslmode=require URL; enables multiple replicas. Tune pool sizing inline, e.g. ?pool_max_conns=10. Empty uses the in-memory single-replica store. See deployment. |
Generate an RSA key (2048-bit minimum):
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out signing.pemStore its PEM contents in SIWE_SIGNING_KEY_1. The kid is derived
deterministically as the RFC 7638 JWK thumbprint of the public key.
- Add the new key as
SIWE_SIGNING_KEY_2and deploy. The JWKS now publishes both; the new key is not yet signing. - After validators refresh their JWKS cache, promote the new key to
SIWE_SIGNING_KEY_1(and demote the old toSIWE_SIGNING_KEY_2) and deploy. New tokens are signed by the new key; the old key still verifies outstanding tokens. - After
TOKEN_TTLelapses (no tokens from the old key remain valid), drop the old key and deploy.
The same convention and rotation procedure apply to the /exchange surface
under EXCHANGE_SIGNING_KEY_*, which carries its own independent key.
Exchanges a /siwe address-control token for a permission token scoped to a DIMO
asset, after validating on-chain/SACD access. It serves both an HTTP API and a
gRPC TokenExchangeService, and publishes its own JWKS/discovery (taking over
the endpoints DEX used to serve for the roles-rights issuer).
caller /exchange
│ POST /exchange/tokens/exchange │
│ Authorization: Bearer <siwe token> │ verify sign-in token signature (JWKS),
│ { asset, permissions, cloudEvents } │ require registered dev license,
│ ──────────────────────────────────────▶ read ethereum_address from the token,
│ │ check SACD/on-chain access for the asset,
│ ◀────────────────────────────────────── mint permission token
│ { token } │
The inbound token must be a valid sign-in token, signature-checked, whose
ethereum_address belongs to an address registered as a developer license in
identity-api. Because both surfaces run in one process, the exchange validates
that token against the /siwe keyset in memory — there is no HTTP fetch of
its own /siwe/keys (which wouldn't be listening yet at startup). Authorization
(the SACD/on-chain grantee check) reads that same ethereum_address claim.
Requires Authorization: Bearer <dauth token>. Request:
{
"asset": "did:erc721:137:0xbA5738…:7",
"permissions": ["…"],
"cloudEvents": { "events": [ … ] },
"audience": ["dimo.zone"]
}audience is optional (defaults to ["dimo.zone"]). The legacy tokenId,
privileges, and nftContractAddress fields are still accepted but
deprecated in favor of asset and permissions. Response:
{ "token": "eyJ…" }The minted token's claims include asset, permissions, and cloud_events
alongside the standard iss / sub / aud / exp / nbf / iat / jti.
GET /— health check ({"data":"Server is up and running"}), served at the process root (shared by both surfaces).GET /exchange/swagger/— interactive OpenAPI docs.GET /exchange/keys(alias…/exchange/.well-known/jwks.json) — JWKS for the permission-token signing key.GET /exchange/.well-known/openid-configuration— OIDC discovery metadata.
TokenExchangeService is served on GRPC_PORT for the on-chain access-check
client (pkg/grpc).
GET /ping,GET /ready— health probes.GET /metrics— Prometheus metrics./debug/pprof/*— enabled only whenENABLE_PPROF=true.
These configure the /exchange surface (the inbound token is validated
against the /siwe keyset in-process, so there is no JWKS-URL variable). HTTP and
ops listeners are shared with the sign-in surface (HTTP_ADDRESS, OPS_ADDRESS).
| Variable | Required | Default | Notes |
|---|---|---|---|
EXCHANGE_ISSUER |
yes | — | iss on minted tokens, e.g. https://dauth.dimo.zone/exchange. |
BLOCKCHAIN_NODE_URL |
yes | — | Ethereum RPC for SACD/contract reads. |
IDENTITY_URL |
yes | — | identity-api GraphQL endpoint (dev-license + SACD lookups). |
IPFS_BASE_URL |
yes | — | IPFS gateway for template/permission documents. |
EXCHANGE_SIGNING_KEY_1, EXCHANGE_SIGNING_KEY_2, … |
yes | — | PEM RSA private keys (independent of the sign-in surface's). _1 is the active signer. |
TOKEN_EXPIRATION |
no | 10m |
Permission-token lifetime. |
CONTRACT_ADDRESS_SACD |
no | — | SACD contract address. |
CONTRACT_ADDRESS_TEMPLATE |
no | — | Permission-template contract address. |
CONTRACT_ADDRESS_MANUFACTURER |
no | — | Manufacturer NFT contract address. |
CONTRACT_ADDRESS_VEHICLE |
no | — | Vehicle NFT contract address. |
DIMO_REGISTRY_CHAIN_ID |
no | 137 |
Chain id for on-chain registry reads. |
IPFS_TIMEOUT |
no | 30s |
Bounds each IPFS fetch. |
GRPC_PORT |
no | 8086 |
gRPC listen port. |
ENABLE_PPROF |
no | false |
Exposes /debug/pprof/* on the ops server. |
- Container:
docker build -f docker/dockerfile .— one static binary ondistroless/static, exposing the public HTTP port, the ops port, and the gRPC port. - Helm:
charts/dauth/withvalues.yaml(dev) andvalues-prod.yaml. The two surfaces' signing keys (plus the RPC URLs) are pulled via oneExternalSecret, referencing the distinct keys at<ns>/dauth/signing_key_1(sign-in) and<ns>/token-exchange-api/signing_key_1(permissions). - Challenge store and replicas. By default the sign-in surface keeps issued
challenges in an in-memory
nonce.Store, so a challenge must be redeemed on the pod that issued it — keepreplicaCount: 1. To scale out, enable the Postgres store (postgres.enabledin the chart, or setDATABASE_URL): challenges become shared across pods (single-use enforced by an atomicDELETE ... RETURNING), and you can raise the replica count. The schema self-applies at startup, so no migration step is needed. - Downstream consumers of the permission token point their JWKS URL at
…/exchange/keys; consumers validating the sign-in token point at…/siwe/keys. The exchange validates the inbound sign-in token in-process, so no JWKS URL needs wiring between the two surfaces.
go test ./...
go build ./cmd/dauthmake run brings up Postgres (via docker-compose.yml, on host port 5433 to
avoid clashing with a local Postgres on 5432), generates throwaway signing keys
under .local/ on first run, and starts the merged binary with sane local env:
make run # dauth on :8080 (ops :8081, grpc :8086)
make signin # in another shell — drives the full /siwe flow
make db-down # stop Postgres and wipe its volumeThe /siwe sign-in surface works fully offline — EOA signing needs no
backends (set RPC_URL only to test smart-account / EIP-1271 login). The
/exchange exchange boots but can't complete a real exchange without
identity-api, an Ethereum RPC, and IPFS; point IDENTITY_URL,
BLOCKCHAIN_NODE_URL, and IPFS_BASE_URL at real (or mocked) services to
exercise it. To run without Docker, start any Postgres and set DATABASE_URL
yourself (or leave it unset to use the in-memory single-replica store).
The gRPC stubs in pkg/grpc are regenerated from pkg/grpc/*.proto with
buf (buf generate); the plugin versions are pinned via
go install of protoc-gen-go/protoc-gen-go-grpc (see buf.gen.yaml).
With a dauth instance running, scripts/signin.sh drives the full SIWE flow —
it generates a throwaway key, requests a challenge, signs it with Foundry's
cast, exchanges it for a token, and prints the decoded claims (requires cast,
curl, and jq):
BASE_URL=http://localhost:8080 scripts/signin.shWith a dauth instance running, scripts/signin.sh drives the full SIWE flow —
it generates a throwaway key, requests a challenge, signs it with Foundry's
cast, exchanges it for a token, and prints the decoded claims (requires cast,
curl, and jq):
BASE_URL=http://localhost:8080 scripts/signin.sh