> **Can't find what you're looking for?** Use `search_docs` on the docs MCP server at `https://www.openfort.io/api/mcp` to find what you need.
>
> **Have feedback?** Use `submit_feedback` on the same MCP server.

# User + server signers

Sometimes the user owns the wallet, but you still want the server to act for them: buy a token every week, renew a subscription, rebalance a position overnight, or let an agent trade on their behalf. Instead of taking custody, the user's [embedded wallet](https://www.openfort.io/docs/products/embedded-wallet) grants your backend's signer a **scoped [session key](https://www.openfort.io/docs/products/embedded-wallet/react/wallet/actions/session-keys)** with the EIP-7715 `wallet_grantPermissions` standard. The session key can only call the contracts the user approved, and only until it expires.

The wallet stays non-custodial. The user keeps their key, and your server's key never leaves your infrastructure. The user can revoke the session at any time.

:::warning
Session keys need a programmable account. Create the user's wallet as a delegated account (`AccountTypeEnum.DELEGATED_ACCOUNT`, EIP-7702) or a smart account. The SDK creates EOA wallets by default, and an EOA cannot enforce a session key.
:::

## The pattern

Three parts, each holding its own key:

1. **Server signer.** Your backend owns a [backend wallet](https://www.openfort.io/docs/products/server). The user authorizes its **address**; its private key stays in your infrastructure.
2. **Grant (client).** The user's embedded wallet calls `grantPermissions` with that address as the signer, a list of allowed contracts and an expiry. Openfort registers the session on the user's account.
3. **Execute (server).** Your backend creates a transaction for the **user's** account and signs it with the server signer. Openfort accepts the signature because the signer holds a valid session, and rejects calls to contracts outside the grant or after expiry.

You authorize an address, not a secret: no session private key is generated, transmitted or stored.

:::steps
## Provision the server signer

Create a backend wallet to act as the signer. One per user makes each grant auditable and revocable on its own. Store its ID next to the user, and send only its **address** to the client.

```ts [openfort.ts]
import Openfort from '@openfort/openfort-node'

export const openfort = new Openfort(process.env.OPENFORT_SECRET_KEY!, {
  walletSecret: process.env.OPENFORT_WALLET_SECRET!,
})

// The server signer. Save signer.id with the user; return signer.address to the client.
const signer = await openfort.accounts.evm.backend.create()
```

## Grant the session key (client)

The user grants the server signer a session with [`useGrantPermissions`](https://www.openfort.io/docs/products/embedded-wallet/react/hooks/useGrantPermissions). The request is scoped by contract address, bounded by an expiry in **seconds from now**, and can cap the number of calls:

```tsx [GrantAgent.tsx]
import { useGrantPermissions } from '@openfort/react'

function GrantAgent({ signerAddress }: { signerAddress: `0x${string}` }) {
  const { grantPermissions, isLoading, error } = useGrantPermissions()

  const grant = async () => {
    const result = await grantPermissions({
      request: {
        signer: { type: 'account', data: { id: signerAddress } },
        expiry: 60 * 60 * 24 * 7, // one week
        permissions: [
          {
            type: 'contract-call',
            data: { address: USDC_ADDRESS, calls: [] },
            policies: [{ type: { custom: 'call-limit' }, data: { limit: 4 } }],
          },
        ],
      },
    })
    if (result.error) return
    // result.permissionsContext is the Openfort session ID.
  }

  return <button onClick={grant} disabled={isLoading}>{error ? error.shortMessage : 'Allow weekly buys'}</button>
}
```

What the session enforces:

* **Contracts:** the `address` of each `contract-call`, `erc20-token-transfer`, `erc721-token-transfer` or `erc1155-token-transfer` permission. The `calls` list is not enforced, so scope with contract addresses.
* **Expiry:** `expiry` is a duration in seconds. The hook rejects values longer than ten years, which usually means a timestamp was passed by mistake.
* **Call count:** an optional `call-limit` policy on the first permission.

The hook rejects an empty `permissions` array, because it would place no restriction on the key. `gas-limit`, `rate-limit`, `token-allowance` and `native-token-transfer` are not supported by embedded wallets and return an error.

## Execute for the user (server)

Your backend creates a transaction for the user's account with [Transactions](https://www.openfort.io/docs/api-reference/transactions), signs the hash it returns with the server signer, and submits the signature:

```ts [execute.ts]
import { encodeFunctionData, erc20Abi } from 'viem'
import { openfort } from './openfort'

export async function executeForUser(userAccountId: string, signerId: string) {
  const signer = await openfort.accounts.evm.backend.get({ id: signerId })

  const tx = await openfort.transactions.create({
    chainId: 84532,
    accountId: userAccountId, // the user's embedded account (acc_...)
    feeSponsorshipId: process.env.OPENFORT_FEE_SPONSORSHIP_ID, // user pays no gas
    calls: [
      {
        to: USDC_ADDRESS,
        data: encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [MERCHANT, 1_000_000n] }),
      },
    ],
  })
  if (tx.status !== 'awaiting_signature' || !tx.nextAction) throw new Error(`Transaction ${tx.id} is ${tx.status}`)

  const signature = await signer.sign({ hash: tx.nextAction.hash as `0x${string}` })
  // Waits for the receipt by default; pass waitForReceipt: false and poll transactions.get(id) instead.
  const result = await openfort.transactions.signature(tx.id, { signature })
  return result.receipt?.transactionHash
}
```

Find the user's account ID with `openfort.accounts.evm.embedded.list({ user })`, and check that it belongs to the signed-in user before acting on it. A call to a contract outside the grant, or after the session expires or is revoked, fails, so the server can never exceed what the user approved.

## Revoke (client)

Let the user end the session early with [`useRevokePermissions`](https://www.openfort.io/docs/products/embedded-wallet/react/hooks/useRevokePermissions). Pass the **signer address** that was granted:

```tsx [RevokeAgent.tsx]
import { useRevokePermissions } from '@openfort/react'

function RevokeAgent({ signerAddress }: { signerAddress: `0x${string}` }) {
  const { revokePermissions, isLoading } = useRevokePermissions()
  return (
    <button onClick={() => revokePermissions({ sessionKey: signerAddress })} disabled={isLoading}>
      Stop weekly buys
    </button>
  )
}
```
:::

## Example: recurring purchases

A dollar-cost-averaging agent is the typical use:

1. The user signs in. Your backend validates the request with `openfort.iam.getSession()` (it returns `null` for an invalid or expired session) and resolves the user's account.
2. When the user turns the feature on, the backend creates one backend wallet per user as the agent and stores its ID with the schedule.
3. The client grants that agent a session limited to the token and swap contracts, with an expiry that matches the schedule and a `call-limit` equal to the number of purchases.
4. A scheduled job (for example a cron route protected by a secret) loops over active agents and calls `executeForUser` for each. A purchase that fails because the session expired or was revoked marks that schedule as stopped.
5. A gas sponsorship pays for every purchase, so the user never needs native gas.

## Choosing between this and a standalone wallet

| | User + server signers | [Server-side user wallets](https://www.openfort.io/docs/products/server/workflows/server-side-user-wallets) |
| :--- | :--- | :--- |
| **Key ownership** | User (non-custodial) | Your server (custodial) |
| **Server's authority** | Only the granted contracts, until expiry | Full control of the wallet |
| **User can revoke** | Yes, any time | No, the server holds the key |
| **Best when** | The user owns funds and delegates a slice | The wallet should be invisible to the user |

## Next steps

* [Session keys](https://www.openfort.io/docs/products/embedded-wallet/react/wallet/actions/session-keys): granting and revoking from React.
* [Gas sponsorship](https://www.openfort.io/docs/configuration/gas-sponsorship): sponsor the server's transactions.
* [Agentic wallets](https://www.openfort.io/docs/products/server/workflows/agentic-wallets): an agent with its own wallet instead of a delegated one.
