Skip to main content

Security

As the authentication SDK deals with core authentication flow, security is the highest concern and is important to get right.

When using authentication flows in production, there are a number of important security concerns to be aware of and address properly, which is what this page will be dedicated to explaining.

In order to explain the details of the security behind the wallet authentication flow, we'll first have to take a look at the details of how the flow works, and we can explore the security details of each piece along the way.

Secure Identification

Secure Identification

The first piece of the authentication flow to understand is the identification flow, which enables backends to securely verify the wallet address of a client-side user.

Our SDK makes use of the sign-in with Ethereum flow (EIP-4361) to verify the client-side user's wallet address, as this specification has been widely agreed about and used for authentication in web3.

Interface

sdk.auth.login(domain: string, options?: LoginOptions): LoginPayload

This function generates a payload signed by the connected wallet that can be used to prove the client-side wallet address to the backend.

type LoginOptions = {
expirationTime?: Date; // Defaults to 5 minutes from now
nonce?: string; // Defaults to UUIDv4
chainId?: number; // Defaults to empty
};

type LoginPayload = {
payload: {
address: string;
nonce: string;
issuedAt: Date;
expirationTime: Date;
chainId?: number;
};
signature: string;
};

const payload = await sdk.auth.login("example.com");

The message that gets signed by this function follows the EIP-4361 compliant format specified below:

${domain} wants you to sign in with your account:
${address}

Make sure that the requesting domain above matches the URL of the current website.

Chain ID: ${chainId}
Nonce: ${nonce}
Expiration Time: ${expirationTime}

The rationale for including each field is the following:

  • domain - Ensures that the user isn't signing on an unintended website (to prevent phishing attacks, explained later)
  • address - Shows the user which wallet they are authenticating with + saves address in the payload to show intent
  • nonce - Provide a nonce in case backends want to make payloads only one-time use (will be a UUID nonce, generated by SDK)
  • expirationTime - Prevent a single payload from being used forever
  • chainId - Optionally pass a chain ID if the payload is being used to read on-chain data

sdk.auth.verify(domain: string, payload: LoginPayload): string

Next, the verify function is used on the backend to verify a login payload. If the payload is valid, it returns the address of the client-side wallet, otherwise it throws an error.

// Generate a payload from the connected wallet on the client side
const payload = await sdk.auth.login("example.com");

// Check if the login payload is valid and get the address
const address = sdk.auth.verify("example.com", payload);

The verification makes a few key checks to ensure the validity of the payload:

  • Ensure that the domain of the signed message matches the domain passed to the SDK
  • Ensure that the address in the payload, the address in the message, and the address that signed the message are all the same
  • Ensure that the expiration time has not come yet

If any of these conditions are not met, this method will throw an error.

User Authentication

User Authentication

Next, we can take a look at the authentication flow which allows the backend to generate a JWT authentication token for use on requests.

Interface

sdk.auth.generateAuthToken(domain: string, payload: LoginPayload, options?: AuthenticationOptions): string

This function generates a JWT token from the login payload to send to the client-side.

type AuthenticationOptions = {
expirationTime?: Date | number; // Defaults to 5 hours
invalidBefore?: Date | number; // Defaults to current time
};

// Generate a payload from the connected wallet on the client side
const loginPayload = await sdk.auth.login("example.com");

// Generate an authentication payload with the login payload
const authToken = await sdk.auth.generateAuthToken("example.com", loginPayload);

The authenticate function takes the following steps

  • First calls sdk.auth.verify(domain, payload) to ensure that the login request is valid
  • If it is, generate a JWT payload structure (structure described below)

The JWT payload to be signed will take the following structure

{
"iss": "0x...", // address of the admin wallet generating the payload
"sub": "0x...", // Address of the user wallet logging in
"aud": "example.com", // Domain that the payload was intended for
"iat": 1653884584, // Unix time (epoch seconds) when payload was issued
"exp": 1653884613, // Unix time (epoch seconds) when payload expires
"nbf": 1653884584, // Time before which payload is invalid, should default to iat
"jti": "82da3d83-9b7b-4cdd-a890-f4d9b25415be" // uuidv4
}
  • Sign the JWT payload with the connected backend admin wallet, which can later be used for verification
  • In addition to the payload and signature, we use the following JWT header for our token:
{
// Specify that we are using the ECDSA SHA-256 hashing algorithm
"alg": "ES256",
// Standard specification for JWT token
"typ": "JWT",
}
  • Construct an actual JWT token from the payload and signature with the following steps, using the following process:
    • First we JSON.stringify both the header and the payload
    • Next, we base64 encode the stringified header, the stringified payload, and the raw signature.
    • Finally, we can construct the JWT token by creating the following . separated structure: {encodedHeader}.{encodedPayload}.{encodedSignature} (without the curly brackets)
    • The following is an example JWT token generated with this method:
    eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIweDllMWI4QTg2ZkZFRTRhNzE3NURBRTRiREIxY0MxMmQxMTFEY2IzRDYiLCJzdWIiOiIweDllMWI4QTg2ZkZFRTRhNzE3NURBRTRiREIxY0MxMmQxMTFEY2IzRDYiLCJhdWQiOiJ0aGlyZHdlYi5jb20iLCJleHAiOjE2NTU1MTcwNjYsIm5iZiI6MTY1NTQ5OTA2NiwiaWF0IjoxNjU1NDk5MDY2LCJqdGkiOiI1M2I1M2JlOC0zMWVmLTQzZjAtYjE4OS0zYTFiMWZjOTU4MjUifQ.MHg5ZTlkZTkxYmVmZTlmZGRjOWQzYWQxY2M5ODNjOWIzNzFhMmUwNGU2OTM1OGVkNjg1MWU5ZDM2ZTEzMWJmNjU4NDUwNDYxOTM1ZDlkMjcxODgwYTRkMTE1ZGVlNjk0OTkwNzMwN2ViZWM4ZmRkZTI3NTgxMDczNjllMTFkNWU5ZTFi
    • You can verify that the above token has a format compliant with the JWT standard by plugging it in to the JWT token debugger on the official JWT website https://jwt.io

sdk.auth.authenticate(domain: string, token: string): string

This function authenticates a JWT token generated with the generateAuthToken function. Throws an error if the token is invalid, otherwise returns the address of the authenticating wallet.

// Generate a payload from the connected wallet on the client side
const loginPayload = await sdk.auth.login("example.com");

// Generate a JWT token
const token = await sdk.auth.generateAuthToken("example.com", loginPayload);

// Verify the auth token and extract the authenticating address
const address = sdk.auth.authenticate("example.com", token);

The verification step should verify all of the following:

  • Ensure that the signing authority matches the iss field
  • Check that the current time is after nbf and before exp
  • Check that the provided domain matches the aud field

Using Refresh Tokens

Refresh Tokens

If you want to take advantage of standard refresh token flows using authentication providers, you can also do this with the authentication SDK with the following process:

  • Use the sdk.auth.login() function and sdk.auth.verify() functions to securely get the users wallet address to the backend.
  • Then, you can use this requesting user to generate an authentication payload with a third-party auth flow like auth0, and relay the requested access and refresh tokens to the user.

Refresh tokens allow you to implement authentication tokens with shorter expiration times, so that any one token can only be used for a small amount of time by a potential hacker, even if it is stolen.

Security Considerations

1. Secure Client-Side Token Storage

Just like the standard JWT flow, the authentication token is generated by the backend service and can be sent to the client-side user. The client-side user can then use it for authentication for as long as the payload is valid.

This means that if anyone obtains the auth token through any means, they can also authenticate with the token, which is why it’s crucial that the auth token is stored securely on the client-side.

Storing JWT tokens in localStorage or ordinary cookies is not a secure way to store tokens on the client-side, as these methods are prone to XSS attacks.

Our current recommended method of using these tokens is by setting httpOnly and secure headers on cookies from the backend - this lets the backend send secure cookies to the client-side that the frontend actually has no access to, which mitigates the risk of XSS attacks.

You can see an example implementation of this setup in our Basic Authentication Example

2. Phishing on Login Request

The authentication flow relies on the user signing a message on the frontend to prove their identity and sending this to the backend. The backend can then use this message to verify the user's identity and issue an authentication token.

This means that if a malicious website is able to get a user to sign a message with the same structure as that accepted by the backend, then they can use this signature to authenticate as the user.

Thus, it’s important that the signed message provides sufficient information to prevent such phishing attacks, which is why the message has the domain and the message indicating the user to check that the domain field matches the website so they are aware of what they are signing.

3. One Time Use Signatures

Login request signatures (created by sdk.auth.login()) have a 5-minute expiration time by default to minimize the amount of time that they are valid for making repeat authentication attempts.

It may be optimal in some cases to only enable signatures to be used once each. Because of this consideration, we’ve included a nonce on each signature so that backends that want to enforce this functionality can store nonces in their database and ensure that no previously used nonces can be used again.

In the future, we’ll offer a self-hosted option (as mentioned below) where we can handle this for users.