Eu precisava que vários sites independentes funcionassem como um único sistema de contas sem enfraquecer o isolamento normal do navegador. Uma pessoa poderia iniciar sessão num domínio, abrir mais tarde outro domínio diretamente, clicar em Iniciar sessão e continuar ali sem voltar a introduzir as credenciais.
A restrição importante era que se tratava de domínios raiz independentes. Um cookie de sessão criado por um domínio raiz não pode simplesmente ter o seu âmbito alargado a outro domínio raiz sem relação com o primeiro. O desenho ficou muito mais fácil de compreender quando separei dois conceitos que muitas vezes são misturados: identidade global e sessão local do navegador.
O modelo resultante é simples: o backend reconhece uma única identidade de conta comum a todos os sites, cada domínio possui a sua própria sessão vinculada ao host e uma transferência de uso único e curta duração permite que uma origem autenticada e de confiança autorize a criação de uma nova sessão noutra origem de confiança.
Uma identidade global não exige um único cookie entre domínios
A conta pode ser global mesmo quando as sessões do navegador são locais.
Cada site mantém um cookie de sessão exclusivo do host. Um cookie emitido por um domínio raiz não pode ser estendido a outro domínio raiz sem relação com ele através do atributo Domain. Esse atributo só pode ampliar o âmbito de um cookie dentro da hierarquia de domínios que o host emissor está autorizado a utilizar.
Para uma sessão que deve permanecer vinculada a um único host, um cookie com o prefixo __Host- é uma opção adequada. Conceitualmente:
Set-Cookie: __Host-<session-cookie>=<opaque-token>; Path=/; Secure; HttpOnly; SameSite=LaxO importante são os limites:
- O Site A não pode ler o cookie de sessão exclusivo do host do Site B.
- O Site B não pode ler o cookie de sessão exclusivo do host do Site A.
- As duas sessões ainda podem corresponder à mesma identidade no backend.
Por isso, o SSO entre domínios não deve tentar mover um cookie de um site para outro. Deve transferir uma prova temporária de que uma origem de confiança já autenticou a pessoa.
O padrão de transferência de uso único
A origem autenticada expõe uma pequena operação de transferência. O navegador chama essa operação apenas quando é necessária uma transição de autenticação entre sites.
O backend gera um ticket criptograficamente aleatório, devolve o valor em bruto ao navegador uma única vez e armazena apenas um hash juntamente com a informação mínima necessária para validar a transferência:
{
tokenHash,
userId,
sourceOrigin,
targetSite,
returnPath,
expiresAt
}O ticket deve ter curta duração e ser válido para um único destino. A solicitação de emissão no lado da origem já deve estar autenticada e protegida contra CSRF. O destino solicitado e o caminho de retorno devem ser validados antes de qualquer emissão.
Em seguida, o navegador faz um POST de nível superior para o destino. O destino consome o ticket, resolve a identidade comum da conta, cria a sua própria sessão local e redireciona o navegador para o caminho pretendido.
Conceitualmente:
record = consumeOnce({
tokenHash: hash(ticket),
targetSite: currentSite,
sourceOrigin: requestOrigin,
notExpired: true
})
if (!record) deny()
user = loadSharedUser(record.userId)
createLocalSession(user)
redirect(record.returnPath)O ticket não é a sessão de destino. É uma autorização de uso único para criar essa sessão de destino.
O consumo atómico fecha a janela de repetição
Uma implementação mais fraca carregaria o ticket, faria a validação, criaria uma sessão e só depois eliminaria o ticket. Isso cria uma janela em que duas solicitações podem observar a mesma entrada ainda válida.
O desenho mais seguro combina validação e invalidação numa única operação atómica de consumo. Se nenhuma entrada corresponder, a autenticação falha. Se uma entrada for devolvida, esse ticket já terá sido removido do conjunto utilizável antes da criação da sessão.
Isso também permite que o destino associe várias condições à mesma operação: destino esperado, origem de procedência esperada, hash do token e expiração.
A validação de Origin deve rejeitar por padrão
Vincular a origem acrescenta outra propriedade útil: um ticket emitido através de uma origem de confiança não deve poder ser consumido a partir de uma origem sem relação com ela.
A verificação da origem deve fazer parte da própria condição de consumo. Isso significa que uma solicitação proveniente da origem errada não consome o ticket válido, enquanto a origem legítima ainda pode concluir a transferência depois.
Há uma ressalva importante: não há garantia de que um cabeçalho Origin contenha uma string de origem normal em todos os contextos do navegador. Algumas solicitações podem chegar sem um valor utilizável, e contextos opacos podem produzir Origin: null.
A regra que sigo é tratar um Origin ausente ou opaco como uma verificação de origem falhada, a menos que a aplicação tenha um mecanismo alternativo explícito de validação. O valor literal null não deve tornar-se uma origem de confiança apenas por compatibilidade.
Esta é uma escolha de rejeição por padrão. Se um fluxo legítimo precisar de suportar um contexto sem uma origem utilizável, essa exceção deve ser desenhada explicitamente em vez de enfraquecer silenciosamente a verificação geral.
Mantenha a credencial de transferência fora do URL
Prefiro enviar o ticket temporário num POST de formulário de nível superior em vez de o colocar numa string de consulta.
Uma versão generalizada do lado do cliente é:
const form = document.createElement('form');
form.method = 'POST';
form.action = targetOrigin + '/auth/consume';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'ticket';
input.value = ticket;
form.append(input);
document.body.append(form);
form.submit();O destino aceita o ticket a partir do corpo POST esperado e rejeita tentativas equivalentes de o fornecer através do URL.
Isto não torna a credencial inofensiva, mas mantém-na fora dos URLs normais de navegação e reduz a exposição acidental através do histórico de URLs, links copiados, referenciadores e registos orientados para URLs.
Links autenticados entre sites podem melhorar a própria navegação
Se o site atual já souber que existe uma sessão autenticada, um link normal para outro site de confiança pode realizar oportunisticamente a transferência.
O link deve continuar a conter um href real. O SSO é uma melhoria da navegação normal, não um substituto para ela.
Um fluxo reduzido no cliente fica assim:
if (session.status !== 'authenticated') {
return; // ordinary browser navigation
}
if (!isTrustedTarget(destination)) {
return;
}
event.preventDefault();
const ticket = await requestHandoff({
targetSite,
returnPath: destinationPath
});
postTicketToTarget(ticket);A versão de produção também deve proteger contra envios duplicados, validar o destino com base num catálogo de sites de confiança e voltar à navegação normal se não for possível criar a transferência.
O comportamento nativo do navegador deve continuar nativo. O clique com o botão do meio, cliques com teclas modificadoras, downloads e links destinados a outro contexto de navegação não devem ser silenciosamente convertidos em solicitações de autenticação.
O botão Iniciar sessão no segundo site precisa de um emissor conhecido
O caso mais difícil começa quando a pessoa abre diretamente o Site B. O Site B não tem uma sessão local, por isso trata corretamente o visitante como não autenticado, mesmo que o navegador ainda tenha uma sessão válida no Site A.
O Site B não pode inspecionar o cookie exclusivo do host do Site A. Também não pode redirecionar para um Site C arbitrário e esperar que esse site descubra sessões pertencentes ao restante sistema. Cada domínio raiz sem relação com os outros só consegue observar os cookies que lhe pertencem.
Isso significa que um fluxo de Iniciar sessão para quem ainda não está autenticado precisa de um emissor conhecido: uma origem de autenticação canónica ou um site de confiança especificamente selecionado que se espera que mantenha a sessão de início de sessão reutilizável.
Para o caso simples Site A → Site B, a sequência é:
- A pessoa inicia sessão no Site A.
- Mais tarde, abre diretamente o Site B.
- O Site B não tem sessão, por isso mostra Iniciar sessão.
- A pessoa clica em Iniciar sessão.
- O navegador realiza uma navegação de nível superior para o Site A, que é o emissor selecionado.
- O Site A pode ler o seu próprio cookie de sessão exclusivo do host já existente.
- Como essa sessão ainda é válida, o Site A emite imediatamente uma transferência de uso único para o Site B.
- O navegador envia o ticket por POST para o Site B.
- O Site B cria a sua própria sessão local e devolve a pessoa à página solicitada.
Do ponto de vista de quem está a usar o sistema, trata-se de um redirecionamento rápido para o Site A e de volta. Não é necessária uma palavra-passe porque o Site A já possui a sessão que tem permissão para observar.
A limitação é igualmente importante: se a sessão existir apenas no Site C, redirecionar o Site B para o Site A não ajuda, a menos que o Site A seja um verdadeiro intermediário central de autenticação com conhecimento independente do estado de início de sessão. Uma base de dados de contas em comum não torna partilhadas as sessões do navegador.
Por que um emissor canónico simplifica o sistema
À medida que aumenta o número de sites independentes, um emissor canónico elimina a necessidade de descobrir sessões entre domínios arbitrários.
Sem esse emissor, um Site B não autenticado teria de decidir de alguma forma que outro site poderia ter naquele momento um cookie útil. O Site A não pode inspecionar o cookie do Site C, o Site C não pode inspecionar o cookie do Site A e o Site B não pode inspecionar nenhum deles.
Um emissor canónico oferece a cada tentativa de início de sessão sem autenticação prévia um caminho previsível:
Site B
→ authentication issuer
→ issuer session exists?
yes → create one-time handoff
no → show normal login
→ Site B consumes handoff
→ Site B creates local sessionO emissor não precisa de servir o restante da aplicação. A sua responsabilidade relevante é possuir a sessão de autenticação reutilizável e emitir transferências com âmbito estritamente limitado para sites de confiança.
Num sistema menor, um site existente pode desempenhar esse papel. Numa arquitetura mais ampla, uma origem dedicada à autenticação pode tornar o modelo de confiança mais fácil de compreender.
Não faça todos os visitantes passar pelo emissor
O fluxo do emissor não deve ser executado automaticamente em cada visualização de página sem autenticação.
Redirecionar todos os visitantes para outro domínio apenas para verificar se existe uma sessão cria navegação desnecessária, latência adicional, mais modos de falha, dados de análise mais ruidosos e um comportamento de cache mais complicado.
Também é uma má opção para páginas públicas sensíveis a SEO. Um crawler ou visitante comum que solicite um URL público deve receber essa página pública diretamente, em vez de ser obrigado a passar primeiro por um desvio de autenticação.
A regra que utilizo é:
Não consulte o emissor ao carregar a página. Inicie a autenticação entre domínios apenas após uma ação explícita de Iniciar sessão ou uma navegação intencional e autenticada entre sites.
Isto mantém simples o tráfego público e, ao mesmo tempo, preserva uma autenticação rápida para quem já tem uma sessão válida no emissor selecionado.
A validação do caminho de retorno exige mais cuidado do que uma verificação de prefixo
A transferência normalmente precisa de lembrar onde o navegador deve chegar depois. Esse valor pode transformar-se num redirecionamento aberto se for tratado como um URL arbitrário controlado pelo cliente.
Quando possível, o desenho mais seguro é não aceitar um URL de destino completo. Um identificador curto ou um mapeamento no servidor para destinos conhecidos reduz a quantidade de análise e validação de URL necessária.
Se for necessário aceitar um caminho relativo, valide-o cuidadosamente e mantenha-o limitado ao site de destino.
Um exemplo deliberadamente reduzido — não uma rotina completa de validação de redirecionamento — é:
function isSafeReturnPath(value) {
if (!value.startsWith('/')) return false;
if (value.startsWith('//')) return false;
if (value.includes('\\\\')) return false;
const base = 'https://example.invalid';
const parsed = new URL(value, base);
return parsed.origin === base;
}Uma implementação real também precisa de considerar o comportamento de descodificação, entradas malformadas, caracteres de controlo, normalização, regras de encaminhamento da aplicação e quaisquer transformações feitas pelo proxy ou framework antes de produzir o redirecionamento.
A invariante de segurança é mais simples do que os detalhes do analisador: a transferência pode selecionar uma localização permitida na aplicação de destino, nunca um destino externo arbitrário.
Os casos de falha importam mais do que o caminho ideal
Um início de sessão bem-sucedido apenas prova que o caminho básico funciona. Os testes mais úteis exercitam os limites de confiança.
Verifiquei o comportamento em casos como:
- uma origem não autenticada a tentar emitir uma transferência;
- proteção CSRF ausente;
- uma origem ausente, opaca ou inesperada;
- um destino não confiável;
- um destino de retorno inseguro;
- um ticket apresentado ao destino errado;
- uma tentativa de consumo a partir da origem errada;
- repetição do ticket;
- expiração do ticket;
- uma conta que já não existe;
- um ticket fornecido através do URL em vez do corpo POST esperado.
Duas invariantes são especialmente úteis para testar diretamente: uma solicitação rejeitada não deve destruir acidentalmente um ticket que ainda seja válido para o fluxo legítimo, e um consumo bem-sucedido deve tornar esse ticket imediatamente inutilizável.
O protocolo escala sem integrações par a par
O desenho da transferência não muda de forma fundamental quando são adicionados mais sites.
Cada site participante precisa do mesmo pequeno contrato:
- um identificador lógico estável;
- uma origem pública de confiança;
- acesso à identidade de conta comum;
- uma operação de emissão de transferências para origens autenticadas;
- uma operação de consumo de transferências;
- um catálogo de sites de confiança;
- uma sessão local vinculada ao host;
- uma política segura para destinos de retorno;
- e um emissor conhecido para fluxos de Iniciar sessão sem autenticação prévia.
A escolha importante para escalar é evitar lógica de autenticação personalizada par a par. Um site já autenticado pode emitir o mesmo tipo de transferência para outro site de confiança. Um site sem autenticação pode enviar o navegador para o emissor conhecido.
Assim, o sistema continua baseado num único protocolo, em vez de numa matriz crescente de casos especiais.
A portabilidade do início de sessão é separada da propagação do fim de sessão
Sessões independentes vinculadas ao host criam outra distinção útil: tornar o início de sessão portátil não define automaticamente um fim de sessão global.
Se a pessoa terminar a sessão no Site A, a sessão local de outro site pode continuar válida, a menos que o backend a revogue intencionalmente. Isso é uma política de gestão de sessões, não uma falha da transferência SSO.
Um sistema pode escolher entre fim de sessão local, fim de sessão global ou gestão explícita de sessões. Manter essa decisão separada da transferência torna ambos os mecanismos mais fáceis de compreender.
O modelo que utilizo
A arquitetura ficou mais simples quando deixei de descrever o problema como “partilhar cookies de início de sessão entre domínios”.
Existe uma identidade global, cada domínio possui a sua própria sessão do navegador e uma transferência de uso único e curta duração permite que uma origem autenticada e de confiança autorize a criação de outra sessão local.
Para a entrada direta num site sem autenticação, há uma regra adicional importante: o navegador deve ser enviado para um emissor que consiga realmente observar a sessão existente. Se a sessão tiver sido iniciada anteriormente nesse emissor, a ida e volta pode ser quase invisível. Se o emissor não tiver uma sessão válida, deve recorrer à autenticação normal, em vez de fingir que consegue ver a sessão de outro domínio sem relação com ele.
Esse modelo preserva os limites de domínio do navegador, evita redirecionamentos de autenticação desnecessários para visitantes de páginas públicas e ainda oferece a experiência pretendida: iniciar sessão no emissor, abrir mais tarde outro site de confiança, clicar em Iniciar sessão e voltar com uma nova sessão local sem introduzir novamente a palavra-passe.