Skip to main content

Using Ethereum for web2 authentication

web2
authentication
eas
Beginner
Ori Pomerantz
30 aprel 2025
20 minute read

Introduction

SAMLopens in a new tab is a standard used on web2 to allow an identity provider (IdP)opens in a new tab to provide user information for service providers (SP)opens in a new tab.

In this tutorial you learn how to integrate Ethereum signatures with SAML to allow users to use their Ethereum wallets to authenticate themselves to web2 services that don't support Ethereum natively yet.

Note that this tutorial is written for two separate audiences:

  • Ethereum people who understand Ethereum and need to learn SAML
  • Web2 people who understand SAML and web2 authentication and need to learn Ethereum

As a result, it is going to contain a lot of introductory material that you already know. Feel free to skip it.

SAML for Ethereum people

SAML is a centralized protocol. A service provider (SP) only accepts assertions (such as "this is my user John, he should have permissions to do A, B, and C") from an identity provider (IdP) if it has a pre-existing trust relationship either with it, or with the certificate authorityopens in a new tab that signed that IdP's certificate.

For example, the SP can be a travel agency providing travel services to companies, and the IdP can be a company's internal web site. When employees need to book business travel, the travel agency sends them for authentication by the company before letting them actually book travel.

Step by step SAML process

This is the way the three entities, the browser, SP, and IdP, negotiate for access. The SP does not need to know anything about the user using the browser in advance, just to trust the IdP.

Ethereum for SAML people

Ethereum is a decentralized system.

Ethereum logon

Users have a a private key (typically held in a browser extension). From the private key you can derive a public key, and from that a 20-byte address. When users need to log into a system, they are requested to sign a message with a nonce (a single-use value). The server can verify the signature was created by that address.

Getting extra data from attestations

The signature only verifies the Ethereum address. To get other user attributes, you typically use attestationsopens in a new tab. An attestation typically has these fields:

  • Attestor, the address that made the attestation
  • Recipient, the address to which the attestation applies
  • Data, the data being attested, such as name, permissions, etc.
  • Schema, the ID of the schema used to interpret the data.

Because of the decentralized nature of Ethereum, any user can make attestations. The attestor's identity is important to identify which attestations we consider reliable.

Setup

The first step is to have a SAML SP and a SAML IdP communicating between themselves.

  1. Download the software. The sample software for this article is on githubopens in a new tab. Different stages are stored in different branches, for this stage you want saml-only

    1git clone https://github.com/qbzzt/250420-saml-ethereum -b saml-only
    2cd 250420-saml-ethereum
    3pnpm install
  2. Create keys with self-signed certificates. This means that the key is its own certificate authority, and needs to be imported manually to the service provider. See the OpenSSL docsopens in a new tab for more information.

    1mkdir keys
    2cd keys
    3openssl req -new -x509 -days 365 -nodes -sha256 -out saml-sp.crt -keyout saml-sp.pem -subj /CN=sp/
    4openssl req -new -x509 -days 365 -nodes -sha256 -out saml-idp.crt -keyout saml-idp.pem -subj /CN=idp/
    5cd ..
  3. Start the servers (both SP and IdP)

    1pnpm start
  4. Browse to the SP at URL http://localhost:3000/opens in a new tab and click the button to be redirected to the IdP (port 3001).

  5. Provide the IdP with your email address and click Login to the service provider. See that you get redirected back to the service provider (port 3000) and that it knows you by your email address.

Detailed explanation

This is what happens, step by step:

Normal SAML logon without Ethereum

src/config.mts

This file contains the configuration for both the Identity Provider and the Service Provider. Normally these two would be different entities, but here we can share code for simplicity.

1const fs = await import("fs")
2
3const protocol="http"

For now we're just testing, so it's fine to use HTTP.

1export const spCert = fs.readFileSync("keys/saml-sp.crt").toString()
2export const idpCert = fs.readFileSync("keys/saml-idp.crt").toString()

Read the public keys, which are normally available to both components (and either trusted directly, or signed by a trusted certificate authority).

1export const spPort = 3000
2export const spHostname = "localhost"
3export const spDir = "sp"
4
5export const idpPort = 3001
6export const idpHostname = "localhost"
7export const idpDir = "idp"
8
9export const spUrl = `${protocol}://${spHostname}:${spPort}/${spDir}`
10export const idpUrl = `${protocol}://${idpHostname}:${idpPort}/${idpDir}`
Hemmesini görkez

The URLs for both components.

1export const spPublicData = {

The public data for the service provider.

1 entityID: `${spUrl}/metadata`,

By convention, in SAML the entityID is the URL where the metadata of the entity is available. This metadata corresponds to the public data here, except it is in XML form.

1 wantAssertionsSigned: true,
2 authnRequestsSigned: false,
3 signingCert: spCert,
4 allowCreate: true,
5 assertionConsumerService: [{
6 Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
7 Location: `${spUrl}/assertion`,
8 }]
9 }
Hemmesini görkez

The most important definition for our purposes is the assertionConsumerServer. It means that to assert something (for example, "the user who sends you this information is somebody@example.comopens email client") to the service provider we need to use HTTP POSTopens in a new tab to URL http://localhost:3000/sp/assertion.

1export const idpPublicData = {
2 entityID: `${idpUrl}/metadata`,
3 signingCert: idpCert,
4 wantAuthnRequestsSigned: false,
5 singleSignOnService: [{
6 Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
7 Location: `${idpUrl}/login`
8 }],
9 singleLogoutService: [{
10 Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
11 Location: `${idpUrl}/logout`
12 }],
13 }
Hemmesini görkez

The public data for the identity provider is similar. It specifies that to log a user in you POST to http://localhost:3001/idp/login and to log a user out you POST to http://localhost:3001/idp/logout.

src/sp.mts

This is the code that implements a service provider.

1import * as config from "./config.mts"
2const fs = await import("fs")
3const saml = await import("samlify")

We use the samlifyopens in a new tab library to implement SAML.

1import * as validator from "@authenio/samlify-node-xmllint"
2saml.setSchemaValidator(validator)

The samlify library expects to have a package validate that XML is correct, signed with the expected public key, etc. We use @authenio/samlify-node-xmllintopens in a new tab for this purpose.

1const express = (await import("express")).default
2const spRouter = express.Router()
3const app = express()

An expressopens in a new tab Routeropens in a new tab is a "mini web site" that can be mounted inside a web site. In this case, we use it to group all the service provider definitions together.

1const spPrivateKey = fs.readFileSync("keys/saml-sp.pem").toString()
2
3const sp = saml.ServiceProvider({
4 privateKey: spPrivateKey,
5 ...config.spPublicData
6})

The service provider's own representation of itself is all the public data, and the private key it uses to sign information.

1const idp = saml.IdentityProvider(config.idpPublicData);

The public data contains everything the service provider needs to know about the identity provider.

1spRouter.get(`/metadata`,
2 (req, res) => res.header("Content-Type", "text/xml").send(sp.getMetadata())
3)

To enable interoperability with other SAML components, service and identity providers should have their public data (called the metadata) available in XML format in /metadata.

1spRouter.post(`/assertion`,

This is the page accessed by the browser to identify itself. The assertion includes the user identifier (here we use email address), and can include additional attributes. This is the handler for step 7 in the sequence diagram above.

1 async (req, res) => {
2 // console.log(`SAML response:\n${Buffer.from(req.body.SAMLResponse, 'base64').toString('utf-8')}`)

You can use the commented out command to see the XML data provided in the assertion. It is base64 encodedopens in a new tab.

1 try {
2 const loginResponse = await sp.parseLoginResponse(idp, 'post', req);

Parse the login request from the identity server.

1 res.send(`
2 <html>
3 <body>
4 <h2>Hello ${loginResponse.extract.nameID}</h2>
5 </body>
6 </html>
7 `)
8 res.send();

Send an HTML response, just to show the user we got the login.

1 } catch (err) {
2 console.error('Error processing SAML response:', err);
3 res.status(400).send('SAML authentication failed');
4 }
5 }
6)

Inform the user in case of failure.

1spRouter.get('/login',

Create a login request when the browser attempts to get this page. This is the handler for step 1 in the sequence diagram above.

1 async (req, res) => {
2 const loginRequest = await sp.createLoginRequest(idp, "post")

Get the information to post a login request.

1 res.send(`
2 <html>
3 <body>
4 <script>
5 window.onload = function () { document.forms[0].submit(); }
6 </script>

This page submits the form (see below) automatically. This way the user does not have to do anything to be redirected. This is step 2 in the sequence diagram above.

1 <form method="post" action="${loginRequest.entityEndpoint}">

Post to loginRequest.entityEndpoint (the URL of the identity provider endpoint).

1 <input type="hidden" name="${loginRequest.type}" value="${loginRequest.context}" />

The input name is loginRequest.type (SAMLRequest). The content for that field is loginRequest.context, which is again XML that is base64 encoded.

1 </form>
2 </body>
3 </html>
4 `)
5 }
6)
7
8app.use(express.urlencoded({extended: true}))

This middlewareopens in a new tab reads the body of the HTTP requestopens in a new tab. By default express ignores it, because most requests don't require it. We need it because POST does use the body.

1app.use(`/${config.spDir}`, spRouter)

Mount the router in the service provider directory (/sp).

1app.get("/", (req, res) => {
2 res.send(`
3 <html>
4 <body>
5 <button onClick="document.location.href='${config.spUrl}/login'">
6 Click here to log on
7 </button>
8 </body>
9 </html>
10 `)
11})
Hemmesini görkez

If a browser tries to get the root directory, provide it with a link to the login page.

1app.listen(config.spPort, () => {
2 console.log(`service provider is running on http://${config.spHostname}:${config.spPort}`)
3})

Listen to the spPort with this express application.

src/idp.mts

This is the identity provider. It is very similar to the service provider, the explanations below are for the parts that are different.

1const xmlParser = new (await import("fast-xml-parser")).XMLParser(
2 {
3 ignoreAttributes: false, // Preserve attributes
4 attributeNamePrefix: "@_", // Prefix for attributes
5 }
6)

We need to read and understand the XML request we receive from the service provider.

1const getLoginPage = requestId => `

This function creates the page with the auto-submitted form that is returned in step 4 of the sequence diagram above.

1<html>
2 <head>
3 <title>Login page</title>
4 </head>
5 <body>
6 <h2>Login page</h2>
7 <form method="post" action="./loginSubmitted">
8 <input type="hidden" name="requestId" value="${requestId}" />
9 Email address: <input name="email" />
10 <br />
11 <button type="Submit">
12 Login to the service provider
13 </button>
Hemmesini görkez

There are two fields we send to the service provider:

  1. The requestId to which we are responding.
  2. The user identifier (we use the email address the user provides for now).
1 </form>
2 </body>
3</html>
4
5const idpRouter = express.Router()
6
7idpRouter.post("/loginSubmitted", async (req, res) => {
8 const loginResponse = await idp.createLoginResponse(

This is the handler for step 5 of the sequence diagram above. idp.createLoginResponseopens in a new tab creates the login response.

1 sp,
2 {
3 authnContextClassRef: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport',
4 audience: sp.entityID,

The audience is the service provider.

1 extract: {
2 request: {
3 id: req.body.requestId
4 }
5 },

Information extracted from the request. The one parameter we care about in the request is the requestId, which lets the service provider match requests and their responses.

1 signingKey: { privateKey: idpPrivateKey, publicKey: config.idpCert } // Ensure signing

We need signingKey to have the data to sign the response. The service provider doesn't trust unsigned requests.

1 },
2 "post",
3 {
4 email: req.body.email

This is the field with the user information we send back to the service provider.

1 }
2 );
3
4 res.send(`
5 <html>
6 <body>
7 <script>
8 window.onload = function () { document.forms[0].submit(); }
9 </script>
10
11 <form method="post" action="${loginResponse.entityEndpoint}">
12 <input type="hidden" name="${loginResponse.type}" value="${loginResponse.context}" />
13 </form>
14 </body>
15 </html>
16 `)
17})
Hemmesini görkez

Again, use an auto-submitted form. This is step 6 of the sequence diagram above.

1
2// IdP endpoint for login requests
3idpRouter.post(`/login`,

This is the endpoint that receives a login request from the service provider. This is the handler the step 3 of the sequence diagram above.

1 async (req, res) => {
2 try {
3 // Workaround because I couldn't get parseLoginRequest to work.
4 // const loginRequest = await idp.parseLoginRequest(sp, 'post', req)
5 const samlRequest = xmlParser.parse(Buffer.from(req.body.SAMLRequest, 'base64').toString('utf-8'))
6 res.send(getLoginPage(samlRequest["samlp:AuthnRequest"]["@_ID"]))

We should be able to use idp.parseLoginRequestopens in a new tab to read the authentication request's ID. However, I couldn't get it working and it wasn't worth spending a lot of time on it so I just use a general-purpose XML parseropens in a new tab. The information we need is the ID attribute inside the <samlp:AuthnRequest> tag, which is at the top level of the XML.

Using Ethereum signatures

Now that we can send a user identity to the service provider, the next step is to obtain the user identity in a trusted manner. Viem allows us to just ask the wallet for the user address, but this means asking the browser for the information. We don't control the browser, so we can't automatically trust the response we get from it.

Instead, the IdP is going to send the browser a string to sign. If the wallet in the browser signs this string, it means that it really is that address (that is, it knows the private key that corresponds to the address).

To see this in action, stop the existing IdP and SP and run these commands:

1git checkout eth-signatures
2pnpm install
3pnpm start

Then browse to the SPopens in a new tab and follow the directions.

Note that at this point we don't know how to get the email address from the Ethereum address, so instead we report <ethereum address>@bad.email.address to the SP.

Detailed explanation

The changes are in steps 4-5 in the previous diagram.

SAML with an Ethereum signature

The only file we changed is idp.mts. Here are the changed parts.

1import { v4 as uuidv4 } from 'uuid'
2import { verifyMessage } from 'viem'

We need these two additional libraries. We use uuidopens in a new tab to create the nonceopens in a new tab value. The value itself does not matter, just the fact it is only used once.

The viemopens in a new tab library lets us use Ethereum definitions. Here we need it to verify that the signature is indeed valid.

1const loginPrompt = "To access the service provider, sign this nonce: "

The wallet asks the user for permission to sign the message. A message that is just a nonce could confuse users, so we include this prompt.

1// Keep requestIDs here
2let nonces = {}

We need the request information to be able to respond to it. We could send it with the request (step 4), and receive it back (step 5). However, we cannot trust the information we get from the browser, which is under the control of a potentially hostile user. So it's better to store it here, with the nonce as key.

Note that we are doing it here as a variable for the sake of simplicity. However, this has several disadvantages:

  • We are vulnerable to a denial of service attack. A malicious user could attempt to log on multiple times, filling up our memory.
  • If the IdP process needs to be restarted, we lose the existing values.
  • We cannot load balance across multiple processes, because each would have its own variable.

On a production system we'd use a database and implement some kind of expiry mechanism.

1const getSignaturePage = requestId => {
2 const nonce = uuidv4()
3 nonces[nonce] = requestId

Create a nonce, and store the requestId for future use.

1 return `
2<html>
3 <head>
4 <script type="module">

This JavaScript gets executed automatically when the page is loaded.

1 import { createWalletClient, custom, getAddress } from 'https://esm.sh/viem'

We need several functions from viem.

1 if (!window.ethereum) {
2 alert("Please install MetaMask or a compatible wallet and then reload")
3 }

We can only work if there is a wallet on the browser.

1 const [account] = await window.ethereum.request({method: 'eth_requestAccounts'})

Request the list of accounts from the wallet (window.ethereum). Assume there is at least one, and only store the first one.

1 const walletClient = createWalletClient({
2 account,
3 transport: custom(window.ethereum)
4 })

Create a wallet clientopens in a new tab to interact with the browser wallet.

1 window.goodSignature = () => {
2 walletClient.signMessage({
3 message: "${loginPrompt}${nonce}"

Ask the user to sign a message. Because this whole HTML is in a template stringopens in a new tab, we can use variables defined in the idp process. This is step 4.5 in the sequence diagram.

1 }).then(signature => {
2 const path= "/${config.idpDir}/signature/${nonce}/" + account + "/" + signature
3 window.location.href = path
4 })
5 }

Redirect to /idp/signature/<nonce>/<address>/<signature>. This is step 5 in the sequence diagram.

1 window.badSignature = () => {
2 const path= "/${config.idpDir}/signature/${nonce}/" +
3 getAddress("0x" + "BAD060A7".padEnd(40, "0")) +
4 "/0x" + "BAD0516".padStart(130, "0")
5 window.location.href = path
6 }

The signature is sent back by the browser, which is potentially malicious (there is nothing to stop you from just opening http://localhost:3001/idp/signature/bad-nonce/bad-address/bad-signature in the browser). Therefore, it is important to verify the IdP process handles bad signatures correctly.

1 </script>
2 </head>
3 <body>
4 <h2>Please sign</h2>
5 <button onClick="window.goodSignature()">
6 Submit a good (valid) signature
7 </button>
8 <br/>
9 <button onClick="window.badSignature()">
10 Submit a bad (invalid) signature
11 </button>
12 </body>
13</html>
14`
15}
Hemmesini görkez

The rest is just standard HTML.

1idpRouter.get("/signature/:nonce/:account/:signature", async (req, res) => {

This is the handler for step 5 in the sequence diagram.

1 const requestId = nonces[req.params.nonce]
2 if (requestId === undefined) {
3 res.send("Bad nonce")
4 return ;
5 }
6
7 nonces[req.params.nonce] = undefined

Get the request ID, and delete the nonce from nonces to make sure it cannot be reused.

1 try {

Becasue there are so many ways in which the signature can be invalid, we wrap this in a try ... catch block to catch any thrown errors.

1 const validSignature = await verifyMessage({
2 address: req.params.account,
3 message: `${loginPrompt}${req.params.nonce}`,
4 signature: req.params.signature
5 })

Use verifyMessageopens in a new tab to implement step 5.5 in the sequence diagram.

1 if (!validSignature)
2 throw("Bad signature")
3 } catch (err) {
4 res.send("Error:" + err)
5 return ;
6 }

The rest of the handler is equivalent to what we've done in the /loginSubmitted handler previously, except for one small change.

1 const loginResponse = await idp.createLoginResponse(
2 .
3 .
4 .
5 {
6 email: req.params.account + "@bad.email.address"
7 }
8 );

We don't have the actual email address (we will get it in the next section), so for now we return the Ethereum address and mark it clearly as not an email address.

1// IdP endpoint for login requests
2idpRouter.post(`/login`,
3 async (req, res) => {
4 try {
5 // Workaround because I couldn't get parseLoginRequest to work.
6 // const loginRequest = await idp.parseLoginRequest(sp, 'post', req)
7 const samlRequest = xmlParser.parse(Buffer.from(req.body.SAMLRequest, 'base64').toString('utf-8'))
8 res.send(getSignaturePage(samlRequest["samlp:AuthnRequest"]["@_ID"]))
9 } catch (err) {
10 console.error('Error processing SAML response:', err);
11 res.status(400).send('SAML authentication failed');
12 }
13 }
14)
Hemmesini görkez

Instead of getLoginPage, now use getSignaturePage in the step 3 handler.

Getting the email address

The next step is to obtain the email address, the identifier requested by the service provider. To do that, we use Ethereum Attestation Service (EAS)opens in a new tab.

The easiest way to get attestations is to use the GraphQL APIopens in a new tab. We use this query:

1query GetAttestationsByRecipient {
2 attestations(
3 where: {
4 recipient: { equals: "${getAddress(ethAddr)}" }
5 schemaId: { equals: "0xfa2eff59a916e3cc3246f9aec5e0ca00874ae9d09e4678e5016006f07622f977" }
6 }
7 take: 1
8 ) {
9 data
10 id
11 attester
12 }
13}
Hemmesini görkez

This schemaIdopens in a new tab includes just an e-mail address. This query asks for attestations of this schema. The subject of the attestation is called the recipient. It is always an Ethereum address.

Warning: The way we are getting attestations here has two security issues.

  • We are going to the API endpoint, https://optimism.easscan.org/graphql, which is a centralized component. We can get the id attribute and then do a lookup onchain to verify that an attestation is real, but the API endpoint can still censor attestations by not telling us about them.

    This problem is not impossible to solve, we could run our own GraphQL endpoint and get the attestations from the chain logs, but that is excessive for our purposes.

  • We don't look at the attester identity. Anybody can feed us false information. In a real world implementation we would have a set of trusted attesters and only look at their attestations.

To see this in action, stop the existing IdP and SP and run these commands:

1git checkout email-address
2pnpm install
3pnpm start

Then provide your e-mail address. You have two ways to do that:

  • Import a wallet using a private key, and use the testing private key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80.

  • Add an attestation for your own e-mail address:

    1. Browse to the schema in the attestation exploreropens in a new tab.

    2. Click Attest with Schema.

    3. Enter your Ethereum address as the recipient, your e-mail address as email address, and select Onchain. Then click Make Attestation.

    4. Approve the transaction in your wallet. You will need some ETH on the Optimism Blockchainopens in a new tab to pay for gas.

Either way, after you do this browse to http://localhost:3000opens in a new tab and follow the directions. If you imported the testing private key, the e-mail you receive is test_addr_0@example.com. If you used your own address, it should be whatever you attested.

Detailed explanation

Getting from Ethereum address to e-mail

The new steps are the GraphQL communication, steps 5.6 and 5.7.

Again, here are the changed parts of idp.mts.

1import { GraphQLClient } from 'graphql-request'
2import { SchemaEncoder } from '@ethereum-attestation-service/eas-sdk'

Import the libraries we need.

1const graphqlEndpointUrl = "https://optimism.easscan.org/graphql"

There is a separate endpoint for each blockchainopens in a new tab.

1const graphqlClient = new GraphQLClient(graphqlEndpointUrl, { fetch })

Create a new GraphQLClient client we can use for querying the endpoint.

1const graphqlSchema = 'string emailAddress'
2const graphqlEncoder = new SchemaEncoder(graphqlSchema)

GraphQL only gives us an opaque data object with bytes. To understand it we need the schema.

1const ethereumAddressToEmail = async ethAddr => {

A function to get from an Ethereum address to an e-mail address.

1 const query = `
2 query GetAttestationsByRecipient {

This is a GraphQL query.

1 attestations(

We are looking for attestations.

1 where: {
2 recipient: { equals: "${getAddress(ethAddr)}" }
3 schemaId: { equals: "0xfa2eff59a916e3cc3246f9aec5e0ca00874ae9d09e4678e5016006f07622f977" }
4 }

The attestations we want are those in our schema, where the recipient is getAddress(ethAddr). The getAddressopens in a new tab function makes sure our address has the correct checksumopens in a new tab. This is necessary about GraphQL is case-significant. "0xBAD060A7", "0xBad060A7", and "0xbad060a7" are differemt values.

1 take: 1

Regardless of how many attestations we find, we only want the first one.

1 ) {
2 data
3 id
4 attester
5 }
6 }`

The fields we want to receive.

  • attester: The address that submitted the attestation. Normally this is used to decide whether to trust the attestation or not.
  • id: The attestation ID. You can use this value to read the attestation onchainopens in a new tab to verify that the information from the GraphQL query is correct.
  • data: The schema data (in this case, the e-mail address).
1 const queryResult = await graphqlClient.request(query)
2
3 if (queryResult.attestations.length == 0)
4 return "no_address@available.is"

If there is no attestation, return a value that is obviously incorrect, but that would appear valid to the service provider.

1 const attestationDataFields = graphqlEncoder.decodeData(queryResult.attestations[0].data)
2 return attestationDataFields[0].value.value
3}

If there is a value, use decodeData to decode the data. We don't need the metadata it provides, just the value itself.

1 const loginResponse = await idp.createLoginResponse(
2 sp,
3 {
4 .
5 .
6 .
7 },
8 "post",
9 {
10 email: await ethereumAddressToEmail(req.params.account)
11 }
12 );
Hemmesini görkez

Use the new function to get the e-mail address.

What about decentralization?

In this configuration users cannot pretend to be somebody they are not, as long as we rely on trustworthy attesters for the Ethereum to e-mail address mapping. However, our identity provider is still a centralized component. Whoever has the private key of the identity provider can send false information to the service provider.

There may be a solution using multi-party computation (MPC)opens in a new tab. I hope to write about it in a future tutorial.

Conclusion

Adoption of a log on standard, such as Ethereum signatures, faces a chicken and egg problem. Service providers want to appeal to the broadest possible market. Users want to be able to access services without having to worry about supporting their log on standard. Creating adapters, such as an Ethereum IdP, can help us get over this hurdle.

Page last update: 23 iýun 2025

Bu gollanma peýdaly boldumy?