Open-source project
dfinity/internet-identity avatar
dfinity/internet-identity

Internet Identity: passkeys, delegations and a per-app identity

Internet Identity, the most prominent authentication system for modern web apps

538 stars197 forksRustNOASSERTION

At a glance

What is it?
Internet Identity is the authentication service of the Internet Computer, and the idea at its centre is small and unusual: instead of an account on a server, a user holds a key on their own device, and authenticating to an application produces a signed delegation that the application can verify without ever seeing the key. The rest of the repository is the machinery around that idea, and the parts of it that are most interesting are the boundary between the stateful part and the stateless part, and the code generation that keeps them in step.
Who is it for?
The Internet Identity repository fits a developer building an application on the Internet Computer who wants users to sign in with a passkey and who wants the integration to go through the published client libraries rather than through the canister interface. It does not fit somebody who wants a conventional account system with passwords.
Can I use it commercially?
Check first. The repository uses a licence we do not classify automatically, so read its LICENSE file before any commercial use.
Is it still maintained?
Yes. The repository last received commits 1 day ago.
What is it written in?
Mainly Rust, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

What the service is

The project describes itself as the most prominent authentication system for modern web apps, and it enables hundreds of thousands of users to log in to applications on the Internet Computer, naming several of them and linking to a directory of the rest.

The three properties it claims are worth reading in the order given, because the first is the most visible and the third is the one that matters most.

The first is simplicity. It uses passkeys, the web authentication standard, for passwordless registration and authentication, so the touch sensor, the face scan, the platform authenticator on a laptop, or a physical security key is what the user presents. It also allows signing in with a Google, an Apple or a Microsoft account through the standard OpenID Connect protocol, which is the pragmatic escape hatch for people who will not set up a passkey.

The second is flexibility, and it is a statement about the integration surface rather than about the user. Integrating is a matter of using the published client libraries, or the ICRC authentication standards, and the readme emphasises that an application does not need to talk to the canister directly. That is a design decision about the boundary, and it is the one that keeps the service usable by more than one client ecosystem.

The third is security, and it has two parts. A unique identity, expressed as a principal, is derived for each application a user authenticates to, which prevents cross-application tracking: an application cannot tell that the same person used a different one, because the identifiers are different by construction. And the cryptographic material never leaves the user's device.

That last clause is the foundation of everything else, and it is worth pausing on what it means. The service does not hold a copy of the key that identifies the user, so it cannot be compromised into impersonating anybody, and it cannot be compelled to hand over a credential, because it does not have one. What it holds is the bookkeeping: which delegations exist, which have been revoked, and the anchor of the keys in the platform's own tamper-evident log.

So the trust model is not that the service is trustworthy. It is that the service is not in a position to impersonate a user even if it wanted to be.

The delegation model, and the two uses of the browser key

The architecture section explains the core mechanism in a sentence that is easy to skim and important to understand: the service works indirectly, by issuing delegations on the user's behalf, which are basically attestations signed with some private cryptographic material owned by the user.

So an application does not receive a session token and does not receive a credential it can replay against a central server. It receives a signed statement about the user, scoped to that application, and it can verify that statement against the public key, whose anchor is published in the platform's log. The application is verifying an assertion, not asking a server who the user is.

The private part of that is the user's key, and the readme says it never leaves the device. The browser's own web authentication API is used twice, and the roles are different.

The first use creates the key material. That happens at registration, and it is the moment the user is asked to present a passkey. Nothing is uploaded: the device keeps the private half, and the service learns the public half.

The second use signs. When the user signs in to an application, the service asks the browser to sign the delegation with the same key, and the signature travels back with the delegation. So the passkey is not a password substitute that gets checked once and exchanged for a token. It is the signing key for every delegation, forever, and every single authentication is a fresh signature by the user's own device.

That is what makes the per-application principal possible, and it is the reason the delegation is scoped. A delegation to one application cannot be presented to another, because the target is part of what is signed, so an application that receives one learns nothing about the user's other applications and cannot correlate them.

The interface between an application and the service is a window message exchange. The application opens the frontend in a separate window, the two exchange messages with the exact payloads described in the documentation, and the delegation comes back. The readme points to a step by step description with a sequence diagram for the message payloads, which is the part an integrator needs.

And the last piece of the design worth naming is revocation. Because the service holds no key material, it cannot invalidate a key; it can record that a delegation has been revoked, and the application checks that record. So revoking access is a check the application performs, and a malicious or broken application that ignores the revocation list is the weak link in the chain.

Two canisters, and why one of them holds no state

The architecture consists of two canisters, and the readme names them and their jobs.

The backend canister manages the user data and the authentication logic. The frontend canister is stateless and serves the web application assets. The split is the interesting part, because the stateless one is the one that faces the browser.

A canister is a WebAssembly module running on the platform, and the assets the browser needs are served by a canister like any other. Making that canister stateless means it can be replicated across the network without any coordination: every replica serves the same files, and there is nothing to keep in step. The stateful one, the backend, holds the bookkeeping and is the one that has to be a single logical service.

The readme points at the interface file for the backend, the candid description of its methods, and says where the code lives for each half. The candid file is the contract, and it is the thing the code generation is based on.

And there is a small detail that says a lot about how the project is worked on: the architecture diagram in the documentation is not just a picture, there is an editable source for it alongside, named after the diagram tool it belongs to. So the diagram is edited as a diagram and exported, rather than drawn once in a tool and left to rot. For a project whose architecture is the thing a reader needs to understand, that is the correct arrangement.

The repository layout follows the same logic. The source directory holds the two canisters and their supporting crates, the interface files sit in the directory of the canister they describe, and the front end has its own source directory inside it, because the front end is a separate application that happens to live in the same repository.

And the workspace manifest confirms the split at the language level: the members are the internet identity canister, the internet identity frontend canister, the asset utility crate, and a canister test crate. So the backend, the frontend, the shared asset handling and the integration tests are four separate compilation units, and the tests are a workspace member rather than a directory, which is what lets the test harness depend on the canisters directly.

Running it locally

The getting started section is unusually practical, and it starts with the case where you do not build anything.

You add a snippet to the canisters list in the project's configuration file, and the snippet points at a pre-built WebAssembly module published with a release, together with its initial arguments, which in the example configure the captcha behaviour: how many unsolved captchas to allow before one is shown, and a trigger that in the example disables captchas outright. Then you run the deploy command, and you have a local instance.

The fact that the local path uses a pre-built module rather than a build is a deliberate shortcut. Somebody trying the service locally is trying the authentication flow, not the Rust build, and making them compile a WebAssembly module first would be an obstacle with no payoff. The release artefact is a compressed module, and the initial arguments are passed as a textual record, which is the platform's own serialisation format.

Then there are the addresses, and the two forms are there for a real reason. In the browsers that support the wildcard local subdomain, the canister identifier is part of the host name. Safari does not support that, so the identifier is passed as a query parameter instead. Any developer who has tried this on both will recognise the shape of the problem immediately, and the readme solves it by giving both forms rather than by telling everybody to use Chrome.

The build section then covers the case where you do want to build it, and it recommends a script over the container file directly, for a specific reason: the container's final stage is a scratch container, which has no shell and no utilities, and it contains the module. A script that extracts the module from the finished container is more dependable than instructions that ask a person to find a file inside an image that has nothing to run.

bash
$ ./scripts/docker-build

And the note about older hardware is a small piece of practical advice: if the bundler tries to load the wrong native binary for the machine, install the node version manager and let it pick the right one. That is a specific symptom, a specific cause and a specific fix, which is the most useful shape a note can have.

There is one more thing about the local setup that is worth reading carefully rather than skimming. The development script sets an environment variable that turns the captcha into a dummy, and another that fetches the root key rather than using a real one. Both are for the local replica, and both are the kind of switch you want to see explicitly declared rather than discovered in a file, because a project that has those switches in a development script is a project that has thought about where they are allowed to be set.

The generated bindings, and why they are checked in

The manifest's longest group of scripts is the generation one, and it is worth reading because it shows a specific discipline.

There is a command that generates the types and the JavaScript for each of the interfaces in the project, and the individual commands show the same shape. Each one runs the platform's interface compiler in bind mode against a candid file, pipes the output through a script that rewrites the platform's own imports, and redirects the result into a generated file in the front end's source tree.

json
"generate:types": "didc bind ./src/internet_identity/internet_identity.did -t ts | ./scripts/rewrite-dfinity-imports > src/frontend/src/lib/generated/internet_identity_types.d.ts"

So there is a pipeline with three stages: the interface file is the source of truth, the interface compiler turns it into bindings, and a rewriting step fixes up the imports so the output is consumable by the front end's bundler. The result is a generated file in the source tree, which means the generated code is committed rather than produced at build time.

Committing generated code is a choice with consequences in both directions, and for this project the argument is clear. The front end is a web application whose build must be reproducible and whose dependencies must be auditable, and a build step that requires the interface compiler to be installed and a network to fetch it is a build step that can fail for reasons that have nothing to do with the front end. The interface compiler is a binary from the platform's toolchain, and putting it in the front end's build path couples two things that are released on different schedules.

The rewrite step in the middle is the other clue. The compiler emits imports in a form that the bundler does not accept, so a script fixes them, and that script is in the repository and is run by the generation command. It is a small, ugly, necessary thing, and the fact that it is a script rather than a patch applied by hand is the difference between a regeneratable file and a file that drifts.

The other scripts in the manifest are the usual ones with two additions. The check command runs the type checker over a separate configuration for the whole project and then a component-aware checker for the front end, and the build command runs an extraction step before the type check and the bundler, so the generated files are refreshed before anything is compiled. The prepare hook synchronises the front end framework's generated routes, and the postinstall hook renders the local initial arguments, which is the piece that turns the deployment configuration into the argument string the local canister expects.

The languages, the tests and the documentation

The repository is a polyglot one, and the file list is a good index of what is in it.

The backend is Rust, and the workspace members make the decomposition explicit: the main canister, the frontend canister, a shared asset utility crate, and a crate of canister tests. The front end is a Svelte application, and the configuration file for that framework, the style configuration, the formatter configuration and the ignore file for it, are all in the root. The bundler configuration, the end to end test configuration, the unit test configuration and the type checking configurations complete the set.

And then the files that are not code, which are the most interesting part of the list.

There is a document about localising the interface, and a configuration file for the localisation tool, which tells you the interface is translated and that the translations live in the repository. For a service used by hundreds of thousands of people across many countries, that is a requirement rather than a nicety, and having the tooling configured in the root is how it stays done.

There is a document about the flows, written in a markup dialect that a documentation site renders, which suggests the flow descriptions are versioned with the code rather than kept in a wiki where they would drift.

There is a hacking document, which the build section points to as the route for building without the container, and which is the usual name for the document that tells a contributor what the build system does not explain.

There is a directory of test vectors, which is what you would expect from a cryptographic project: known answers that the implementation is checked against, so that a change that alters a signature or a delegation format is caught immediately.

There is a directory for the boundary node, which is the component that mediates between the browser and the platform's public network, and a set of checksums for the interface artefacts, and a release marker, and a pinned version of the platform's command line tool, and pinned versions for node and the Rust toolchain. Four separate version pins in one root is a lot of pinning, and it is the right amount for a project whose output has to match a platform's interface definition exactly.

And there is an instruction file for coding assistants, which is a convention that has become common in repositories with a build this particular, because the build has conventions that are not in the documentation and that an assistant would otherwise rediscover by breaking them.

Editorial conclusion

The Internet Identity repository fits a developer building an application on the Internet Computer who wants users to sign in with a passkey and who wants the integration to go through the published client libraries rather than through the canister interface. It does not fit somebody who wants a conventional account system with passwords. Verify first which client libraries you use and that they are current, since the interface they sit on is versioned and the bindings are generated from it, that you understand what a delegation does and does not let the application do, because the delegation is a scoped credential rather than a session, and that the local development setup is using the dummy captcha mode and the test key rather than anything resembling production.

Frequently asked questions

What is Internet Identity?

It is an authentication and identity service for the Internet Computer, where all programs are WebAssembly modules called canisters. It uses passkeys for passwordless registration and authentication, also supports signing in with Google, Apple or Microsoft accounts through OpenID Connect, and issues delegations that applications verify themselves.

What is a delegation in Internet Identity?

It is an attestation signed with private cryptographic material that the user holds on their own device, issued on the user's behalf and scoped to the application being authenticated. The private material never leaves the device, so the service is not in a position to impersonate a user even if it wanted to be.

How does Internet Identity prevent cross-application tracking?

A unique principal is derived for each application a user authenticates to, and the delegation is scoped to that application, so an application cannot correlate the user with their activity in a different one. The consequence is that a delegation issued for one application cannot be presented to another.

What are the two canisters in Internet Identity?

A backend canister that manages user data and the authentication logic, and a stateless frontend canister that serves the web application assets. The stateless one can be replicated without coordination, while the backend holds the bookkeeping. Their interface is described in a candid file, and the bindings are generated from it.

How are the Internet Identity client bindings produced?

The interface compiler is run in bind mode against each candid file, the output is piped through a script that rewrites the platform's own imports so the front end's bundler accepts it, and the result is written into a generated file in the source tree. The generated code is committed rather than produced during the front end's build, so the front end's build does not depend on the platform's toolchain being present.

Official sources

  1. dfinity/internet-identity on GitHub
  2. Issues
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/dfinity-internet-identity.svg)](https://hysenlabs.com/projects/dfinity-internet-identity)

Was this useful?